refactor: consolidate convos state and intent handling

This commit is contained in:
2026-05-30 15:39:43 +03:00
parent 167f980f29
commit f11b8dc6f4
13 changed files with 392 additions and 402 deletions
@@ -3,6 +3,7 @@ package dev.meloda.fast.convos
import android.content.Context
import android.content.res.Resources
import android.os.Bundle
import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import coil.ImageLoader
@@ -15,10 +16,12 @@ import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.extensions.setValue
import dev.meloda.fast.common.extensions.updateValue
import dev.meloda.fast.convos.model.ConvoDialog
import dev.meloda.fast.convos.model.ConvoNavigation
import dev.meloda.fast.convos.model.ConvoIntent
import dev.meloda.fast.convos.model.ConvoNavigationIntent
import dev.meloda.fast.convos.model.ConvosScreenState
import dev.meloda.fast.convos.model.InteractionJob
import dev.meloda.fast.convos.model.NewInteractionException
import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.data.VkUtils
import dev.meloda.fast.data.processState
import dev.meloda.fast.datastore.UserSettings
@@ -28,25 +31,23 @@ import dev.meloda.fast.domain.LongPollUpdatesParser
import dev.meloda.fast.domain.MessagesUseCase
import dev.meloda.fast.domain.util.asPresentation
import dev.meloda.fast.domain.util.extractAvatar
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.ConvosFilter
import dev.meloda.fast.model.InteractionType
import dev.meloda.fast.model.LongPollParsedEvent
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.ui.model.vk.ConvoOption
import dev.meloda.fast.ui.model.vk.UiConvo
import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
import dev.meloda.fast.ui.util.buildImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
@Immutable
class ConvosViewModel(
updatesParser: LongPollUpdatesParser,
private val filter: ConvosFilter,
val filter: ConvosFilter,
private val convoUseCase: ConvoUseCase,
private val messagesUseCase: MessagesUseCase,
private val resources: Resources,
@@ -59,40 +60,18 @@ class ConvosViewModel(
private val screenState = MutableStateFlow(ConvosScreenState.EMPTY)
val screenStateFlow get() = screenState.asStateFlow()
private val navigation = MutableStateFlow<ConvoNavigation?>(null)
val navigationFlow get() = navigation.asStateFlow()
private val navigationIntent = MutableStateFlow<ConvoNavigationIntent?>(null)
val navigationIntentFlow get() = navigationIntent.asStateFlow()
private val dialog = MutableStateFlow<ConvoDialog?>(null)
val dialogFlow get() = dialog.asStateFlow()
private val convos: MutableList<VkConvo> = mutableListOf()
private val convos = MutableStateFlow<List<VkConvo>>(emptyList())
val convosFlow get() = convos.asStateFlow()
private val pinnedConvosCount get() = convos.count(VkConvo::isPinned)
private val uiConvos = MutableStateFlow<List<UiConvo>>(emptyList())
val uiConvosFlow get() = uiConvos.asStateFlow()
private val pinnedConvosCount = convosFlow.map { convos ->
convos.count(VkConvo::isPinned)
}.stateIn(viewModelScope, SharingStarted.Eagerly, 0)
private val baseError = MutableStateFlow<BaseError?>(null)
val baseErrorFlow get() = baseError.asStateFlow()
private val currentOffset = MutableStateFlow(0)
val currentOffsetFlow get() = currentOffset.asStateFlow()
private val canPaginate = MutableStateFlow(false)
val canPaginateFlow get() = canPaginate.asStateFlow()
private val expandedConvoId = MutableStateFlow(0L)
private val useContactNames: Boolean get() = userSettings.useContactNames.value
private var currentOffset = 0
private val interactionsTimers = hashMapOf<Long, InteractionJob?>()
init {
screenState.updateValue { copy(isArchive = filter == ConvosFilter.ARCHIVE) }
loadConvos()
updatesParser.onNewMessage(::handleNewMessage)
@@ -110,100 +89,143 @@ class ConvosViewModel(
}
}
fun onNavigationConsumed() {
navigation.setValue { null }
fun handleIntent(intent: ConvoIntent) {
when (intent) {
ConvoIntent.ArchiveClick -> {
navigationIntent.setValue { ConvoNavigationIntent.Archive }
}
ConvoIntent.Back -> {
navigationIntent.setValue { ConvoNavigationIntent.Back }
}
ConvoIntent.ConsumeScrollToTop -> Unit
ConvoIntent.CreateChatClick -> {
navigationIntent.setValue { ConvoNavigationIntent.CreateChat }
}
ConvoIntent.ErrorActionButtonClick -> {
onRefresh()
}
is ConvoIntent.ItemClick -> {
onConvoItemClick(intent.convoId)
}
is ConvoIntent.ItemLongClick -> {
onConvoItemLongClick(intent.convoId)
}
is ConvoIntent.OptionItemClick -> {
onOptionClicked(intent.option)
}
ConvoIntent.PaginationConditionsMet -> {
onPaginationConditionsMet()
}
ConvoIntent.Refresh -> {
onRefresh()
}
is ConvoIntent.SetScrollIndex -> {
setScrollIndex(intent.index)
}
is ConvoIntent.SetScrollOffset -> {
setScrollOffset(intent.offset)
}
is ConvoIntent.Dialog -> {
when (intent) {
is ConvoIntent.Dialog.Cancel -> Unit
is ConvoIntent.Dialog.Confirm -> onDialogConfirmed(intent.bundle)
ConvoIntent.Dialog.Dismiss -> onDialogDismissed()
}
}
}
}
fun onDialogConfirmed(dialog: ConvoDialog, bundle: Bundle) {
onDialogDismissed(dialog)
fun onNavigationConsumed() {
navigationIntent.setValue { null }
}
private fun onDialogConfirmed(bundle: Bundle?) {
val dialog = screenState.value.dialog ?: return
onDialogDismissed()
val convo = with(screenState.value) {
convos.find { it.id == expandedConvoId }
} ?: return
when (dialog) {
is ConvoDialog.ConvoDelete -> {
deleteConvo(dialog.convoId)
is ConvoDialog.Delete -> {
deleteConvo(convo.id)
}
is ConvoDialog.ConvoPin -> {
pinConvo(dialog.convoId, true)
is ConvoDialog.Pin -> {
pinConvo(convo.id, true)
}
is ConvoDialog.ConvoUnpin -> {
pinConvo(dialog.convoId, false)
is ConvoDialog.Unpin -> {
pinConvo(convo.id, false)
}
is ConvoDialog.ConvoArchive -> {
archiveConvo(dialog.convoId, true)
is ConvoDialog.Archive -> {
archiveConvo(convo.id, true)
}
is ConvoDialog.ConvoUnarchive -> {
archiveConvo(dialog.convoId, false)
is ConvoDialog.Unarchive -> {
archiveConvo(convo.id, false)
}
}
expandedConvoId.setValue { 0 }
collapseConvos(false)
syncUiConvos()
}
fun onDialogDismissed(dialog: ConvoDialog) {
this.dialog.setValue { null }
private fun onDialogDismissed() {
screenState.updateValue { copy(dialog = null) }
}
fun onDialogItemPicked(dialog: ConvoDialog, bundle: Bundle) {
when (dialog) {
is ConvoDialog.ConvoDelete -> Unit
is ConvoDialog.ConvoPin -> Unit
is ConvoDialog.ConvoUnpin -> Unit
is ConvoDialog.ConvoArchive -> Unit
is ConvoDialog.ConvoUnarchive -> Unit
}
}
fun onErrorButtonClicked() {
when (baseErrorFlow.value) {
null -> Unit
is BaseError.ConnectionError,
is BaseError.InternalError,
is BaseError.SimpleError,
is BaseError.UnknownError -> onRefresh()
else -> Unit
}
}
fun onPaginationConditionsMet() {
currentOffset.update { convosFlow.value.size }
private fun onPaginationConditionsMet() {
currentOffset = convos.size
loadConvos()
}
fun onRefresh() {
private fun onErrorConsumed() {
screenState.updateValue { copy(error = null) }
}
private fun onRefresh() {
onErrorConsumed()
loadConvos(offset = 0)
}
fun onConvoItemClick(convo: UiConvo) {
private fun onConvoItemClick(convoId: Long) {
collapseConvos()
navigation.setValue { ConvoNavigation.MessagesHistory(peerId = convo.id) }
navigationIntent.setValue { ConvoNavigationIntent.MessagesHistory(convoId) }
}
fun onConvoItemLongClick(convo: UiConvo) {
expandedConvoId.setValue {
if (convo.isExpanded) 0
else convo.id
}
private fun onConvoItemLongClick(convoId: Long) {
val isExpanded = screenState.value.convos.find { it.id == convoId }?.isExpanded == true
screenState.updateValue { copy(expandedConvoId = if (isExpanded) 0L else convoId) }
syncUiConvos()
}
fun onOptionClicked(
convo: UiConvo,
option: ConvoOption
) {
private fun onOptionClicked(option: ConvoOption) {
val convo =
screenState.value.convos.find { it.id == screenState.value.expandedConvoId } ?: return
when (option) {
ConvoOption.Delete -> {
dialog.setValue { ConvoDialog.ConvoDelete(convo.id) }
}
ConvoOption.Delete -> setDialog(ConvoDialog.Delete)
ConvoOption.MarkAsRead -> {
convo.lastMessageId?.let { lastMessageId ->
val lastMessageId =
screenState.value.convos.find { it.id == screenState.value.expandedConvoId }?.lastMessageId
if (lastMessageId != null) {
readConvo(
peerId = convo.id,
startMessageId = lastMessageId
@@ -212,48 +234,39 @@ class ConvosViewModel(
}
}
ConvoOption.Pin -> {
dialog.setValue { ConvoDialog.ConvoPin(convo.id) }
}
ConvoOption.Unpin -> {
dialog.setValue { ConvoDialog.ConvoUnpin(convo.id) }
}
ConvoOption.Archive -> {
dialog.setValue { ConvoDialog.ConvoArchive(convo.id) }
}
ConvoOption.Unarchive -> {
dialog.setValue { ConvoDialog.ConvoUnarchive(convo.id) }
}
ConvoOption.Pin -> setDialog(ConvoDialog.Pin)
ConvoOption.Unpin -> setDialog(ConvoDialog.Unpin)
ConvoOption.Archive -> setDialog(ConvoDialog.Archive)
ConvoOption.Unarchive -> setDialog(ConvoDialog.Unarchive)
}
}
fun onErrorConsumed() {
baseError.setValue { null }
}
fun setScrollIndex(index: Int) {
private fun setScrollIndex(index: Int) {
screenState.setValue { old -> old.copy(scrollIndex = index) }
}
fun setScrollOffset(offset: Int) {
private fun setScrollOffset(offset: Int) {
screenState.setValue { old -> old.copy(scrollOffset = offset) }
}
fun onCreateChatButtonClicked() {
navigation.setValue { ConvoNavigation.CreateChat }
private fun setDialog(dialog: ConvoDialog?) {
screenState.updateValue { copy(dialog = dialog) }
}
private fun collapseConvos() {
expandedConvoId.setValue { 0 }
syncUiConvos()
private fun replaceConvos(newConvos: List<VkConvo>) {
convos.clear()
convos.addAll(newConvos)
}
private fun loadConvos(
offset: Int = currentOffsetFlow.value
) {
private fun collapseConvos(sync: Boolean = true) {
screenState.updateValue { copy(expandedConvoId = null) }
if (sync) {
syncUiConvos()
}
}
private fun loadConvos(offset: Int = currentOffset) {
convoUseCase.getConvos(
count = LOAD_COUNT,
offset = offset,
@@ -261,21 +274,18 @@ class ConvosViewModel(
).listenValue(viewModelScope) { state ->
state.processState(
error = { error ->
val newBaseError = VkUtils.parseError(error)
baseError.update { newBaseError }
screenState.updateValue { copy(error = VkUtils.parseError(error)) }
},
success = { response ->
val convos = response
val fullConvos = if (offset == 0) {
convos
val newConvos = if (offset == 0) {
response
} else {
this.convosFlow.value.plus(convos)
convos.plus(response)
}
val itemsCountSufficient = response.size == LOAD_COUNT
val paginationExhausted = !itemsCountSufficient &&
this.convosFlow.value.isNotEmpty()
val paginationExhausted = !itemsCountSufficient && convos.isNotEmpty()
screenState.updateValue {
copy(isPaginationExhausted = paginationExhausted)
@@ -294,9 +304,10 @@ class ConvosViewModel(
convoUseCase.storeConvos(response)
this.convos.emit(fullConvos)
replaceConvos(newConvos)
screenState.updateValue { copy(canPaginate = itemsCountSufficient) }
syncUiConvos()
canPaginate.setValue { itemsCountSufficient }
}
)
@@ -314,13 +325,13 @@ class ConvosViewModel(
state.processState(
error = {},
success = {
val newConvos = convosFlow.value.toMutableList()
val newConvos = convos.toMutableList()
val convoIndex =
newConvos.indexOfFirstOrNull { it.id == peerId }
?: return@processState
newConvos.removeAt(convoIndex)
convos.update { newConvos.sorted() }
replaceConvos(newConvos.sorted())
syncUiConvos()
}
)
@@ -338,7 +349,7 @@ class ConvosViewModel(
LongPollParsedEvent.ChatMajorChanged(
peerId = peerId,
majorId = if (pin) {
pinnedConvosCount.value.plus(1) * 16
pinnedConvosCount.plus(1) * 16
} else {
0
}
@@ -357,7 +368,7 @@ class ConvosViewModel(
state.processState(
error = {},
success = {
convosFlow.value.find { it.id == peerId }?.let { convo ->
convos.find { it.id == peerId }?.let { convo ->
handleChatArchived(
LongPollParsedEvent.ChatArchived(
convo = convo,
@@ -374,7 +385,7 @@ class ConvosViewModel(
private fun handleNewMessage(event: LongPollParsedEvent.NewMessage) {
val message = event.message
val newConvos = convosFlow.value.toMutableList()
val newConvos = convos.toMutableList()
val convoIndex =
newConvos.indexOfFirstOrNull { it.id == message.peerId }
@@ -392,8 +403,8 @@ class ConvosViewModel(
val convo = (response.firstOrNull() ?: return@listenValue)
.copy(lastMessage = message)
newConvos.add(pinnedConvosCount.value, convo)
convos.update { newConvos.sorted() }
newConvos.add(pinnedConvosCount, convo)
replaceConvos(newConvos.sorted())
syncUiConvos()
}
)
@@ -429,19 +440,17 @@ class ConvosViewModel(
newConvos[convoIndex] = newConvo
} else {
newConvos.removeAt(convoIndex)
val toPosition = pinnedConvosCount.value
newConvos.add(toPosition, newConvo)
newConvos.add(pinnedConvosCount, newConvo)
}
convos.update { newConvos.sorted() }
replaceConvos(newConvos.sorted())
syncUiConvos()
}
}
private fun handleEditedMessage(event: LongPollParsedEvent.MessageEdited) {
val message = event.message
val newConvos = convosFlow.value.toMutableList()
val newConvos = convos.toMutableList()
val convoIndex = newConvos.indexOfFirstOrNull { it.id == message.peerId }
if (convoIndex == null) { // диалога нет в списке
@@ -453,13 +462,14 @@ class ConvosViewModel(
lastMessageId = message.id,
lastCmId = message.cmId
)
convos.update { newConvos }
replaceConvos(newConvos)
syncUiConvos()
}
}
private fun handleReadIncomingMessage(event: LongPollParsedEvent.IncomingMessageRead) {
val newConvos = convosFlow.value.toMutableList()
val newConvos = convos.toMutableList()
val convoIndex =
newConvos.indexOfFirstOrNull { it.id == event.peerId }
@@ -473,13 +483,13 @@ class ConvosViewModel(
unreadCount = event.unreadCount
)
convos.update { newConvos }
replaceConvos(newConvos)
syncUiConvos()
}
}
private fun handleReadOutgoingMessage(event: LongPollParsedEvent.OutgoingMessageRead) {
val newConvos = convosFlow.value.toMutableList()
val newConvos = convos.toMutableList()
val convoIndex =
newConvos.indexOfFirstOrNull { it.id == event.peerId }
@@ -493,7 +503,7 @@ class ConvosViewModel(
unreadCount = event.unreadCount
)
convos.update { newConvos }
replaceConvos(newConvos)
syncUiConvos()
}
}
@@ -503,7 +513,7 @@ class ConvosViewModel(
val peerId = event.peerId
val userIds = event.userIds
val newConvos = convosFlow.value.toMutableList()
val newConvos = convos.toMutableList()
val convoAndIndex =
newConvos.findWithIndex { it.id == peerId }
@@ -514,7 +524,7 @@ class ConvosViewModel(
interactionIds = userIds
)
convos.update { newConvos }
replaceConvos(newConvos)
syncUiConvos()
interactionsTimers[peerId]?.let { interactionJob ->
@@ -546,7 +556,7 @@ class ConvosViewModel(
private fun stopInteraction(peerId: Long, interactionJob: InteractionJob) {
interactionsTimers[peerId] ?: return
val newConvos = convosFlow.value.toMutableList()
val newConvos = convos.toMutableList()
val convoAndIndex =
newConvos.findWithIndex { it.id == peerId } ?: return
@@ -556,7 +566,7 @@ class ConvosViewModel(
interactionIds = emptyList()
)
convos.update { newConvos }
replaceConvos(newConvos)
syncUiConvos()
interactionJob.timerJob.cancel()
@@ -564,7 +574,7 @@ class ConvosViewModel(
}
private fun handleChatMajorChanged(event: LongPollParsedEvent.ChatMajorChanged) {
val newConvos = convosFlow.value.toMutableList()
val newConvos = convos.toMutableList()
val convoIndex =
newConvos.indexOfFirstOrNull { it.id == event.peerId }
@@ -574,13 +584,13 @@ class ConvosViewModel(
newConvos[convoIndex] =
newConvos[convoIndex].copy(majorId = event.majorId)
convos.setValue { newConvos.sorted() }
replaceConvos(newConvos.sorted())
syncUiConvos()
}
}
private fun handleChatMinorChanged(event: LongPollParsedEvent.ChatMinorChanged) {
val newConvos = convosFlow.value.toMutableList()
val newConvos = convos.toMutableList()
val convoIndex =
newConvos.indexOfFirstOrNull { it.id == event.peerId }
@@ -590,13 +600,13 @@ class ConvosViewModel(
newConvos[convoIndex] =
newConvos[convoIndex].copy(minorId = event.minorId)
convos.setValue { newConvos.sorted() }
replaceConvos(newConvos.sorted())
syncUiConvos()
}
}
private fun handleChatClearing(event: LongPollParsedEvent.ChatCleared) {
val newConvos = convosFlow.value.toMutableList()
val newConvos = convos.toMutableList()
val convoIndex = newConvos.indexOfFirstOrNull { it.id == event.peerId }
@@ -605,7 +615,7 @@ class ConvosViewModel(
} else {
newConvos.removeAt(convoIndex)
convos.setValue { newConvos.sorted() }
replaceConvos(newConvos.sorted())
syncUiConvos()
}
}
@@ -613,7 +623,7 @@ class ConvosViewModel(
private fun handleChatArchived(event: LongPollParsedEvent.ChatArchived) {
val convo = event.convo
val newConvos = convosFlow.value.toMutableList()
val newConvos = convos.toMutableList()
when (filter) {
ConvosFilter.BUSINESS_NOTIFY -> Unit
@@ -628,7 +638,7 @@ class ConvosViewModel(
newConvos.removeAt(index)
}
convos.update { newConvos }
replaceConvos(newConvos)
syncUiConvos()
}
@@ -639,10 +649,10 @@ class ConvosViewModel(
newConvos.removeAt(index)
} else {
newConvos.add(pinnedConvosCount.value, convo)
newConvos.add(pinnedConvosCount, convo)
}
convos.update { newConvos.sorted() }
replaceConvos(newConvos.sorted())
syncUiConvos()
}
}
@@ -656,7 +666,7 @@ class ConvosViewModel(
state.processState(
error = {},
success = {
val newConvos = convosFlow.value.toMutableList()
val newConvos = convos.toMutableList()
val convoIndex =
newConvos.indexOfFirstOrNull { it.id == peerId }
?: return@listenValue
@@ -664,7 +674,7 @@ class ConvosViewModel(
newConvos[convoIndex] =
newConvos[convoIndex].copy(inRead = startMessageId)
convos.update { newConvos }
replaceConvos(newConvos)
syncUiConvos()
}
)
@@ -696,47 +706,44 @@ class ConvosViewModel(
}
private fun syncUiConvos(): List<UiConvo> {
val convos = convosFlow.value
val newUiConvos = convos.map { convo ->
val options = mutableListOf<ConvoOption>()
convo.lastMessage?.run {
if (!convo.isRead() && !this.isOut) {
options += ConvoOption.MarkAsRead
val options: ImmutableList<ConvoOption> = buildImmutableList {
if (!convo.isRead() && convo.lastMessage != null && convo.lastMessage?.isOut == false) {
add(ConvoOption.MarkAsRead)
}
if (convo.isPinned()) {
add(ConvoOption.Unpin)
}
if (convos.size > 4 && pinnedConvosCount < 5 && !convo.isPinned()) {
add(ConvoOption.Pin)
}
when (filter) {
ConvosFilter.BUSINESS_NOTIFY -> Unit
ConvosFilter.ARCHIVE -> add(ConvoOption.Unarchive)
ConvosFilter.ALL,
ConvosFilter.UNREAD -> {
if (convo.id != UserConfig.userId) {
add(ConvoOption.Archive)
}
}
}
add(ConvoOption.Delete)
}
val convosSize = this.convosFlow.value.size
val pinnedCount = pinnedConvosCount.value
val canPinOneMoreDialog =
convosSize > 4 && pinnedCount < 5 && !convo.isPinned()
if (convo.isPinned()) {
options += ConvoOption.Unpin
} else if (canPinOneMoreDialog) {
options += ConvoOption.Pin
}
when (filter) {
ConvosFilter.ARCHIVE -> ConvoOption.Unarchive
ConvosFilter.UNREAD,
ConvosFilter.ALL -> ConvoOption.Archive
ConvosFilter.BUSINESS_NOTIFY -> null
}?.let(options::add)
options += ConvoOption.Delete
convo.asPresentation(
resources = resources,
useContactName = useContactNames,
isExpanded = expandedConvoId.value == convo.id,
options = options.toImmutableList()
useContactName = userSettings.useContactNames.value,
isExpanded = screenState.value.expandedConvoId == convo.id,
options = options
)
}
uiConvos.setValue { newUiConvos }
screenState.updateValue { copy(convos = newUiConvos.toImmutableList()) }
return newUiConvos
}
@@ -4,9 +4,9 @@ import androidx.compose.runtime.Immutable
@Immutable
sealed class ConvoDialog {
data class ConvoPin(val convoId: Long) : ConvoDialog()
data class ConvoUnpin(val convoId: Long) : ConvoDialog()
data class ConvoDelete(val convoId: Long) : ConvoDialog()
data class ConvoArchive(val convoId: Long) : ConvoDialog()
data class ConvoUnarchive(val convoId: Long) : ConvoDialog()
data object Pin : ConvoDialog()
data object Unpin : ConvoDialog()
data object Delete : ConvoDialog()
data object Archive : ConvoDialog()
data object Unarchive : ConvoDialog()
}
@@ -0,0 +1,29 @@
package dev.meloda.fast.convos.model
import android.os.Bundle
import dev.meloda.fast.ui.model.vk.ConvoOption
sealed class ConvoIntent {
data class ItemClick(val convoId: Long) : ConvoIntent()
data class ItemLongClick(val convoId: Long) : ConvoIntent()
data class OptionItemClick(val option: ConvoOption) : ConvoIntent()
data object PaginationConditionsMet : ConvoIntent()
data object Back : ConvoIntent()
data object Refresh : ConvoIntent()
data object CreateChatClick : ConvoIntent()
data object ArchiveClick : ConvoIntent()
data class SetScrollIndex(val index: Int) : ConvoIntent()
data class SetScrollOffset(val offset: Int) : ConvoIntent()
data object ErrorActionButtonClick : ConvoIntent()
data object ConsumeScrollToTop : ConvoIntent()
sealed class Dialog : ConvoIntent() {
data object Dismiss : Dialog()
data class Confirm(val bundle: Bundle? = null) : Dialog()
data class Cancel(val bundle: Bundle? = null) : Dialog()
}
}
@@ -1,11 +0,0 @@
package dev.meloda.fast.convos.model
import androidx.compose.runtime.Immutable
@Immutable
sealed class ConvoNavigation {
data class MessagesHistory(val peerId: Long) : ConvoNavigation()
data object CreateChat : ConvoNavigation()
}
@@ -0,0 +1,9 @@
package dev.meloda.fast.convos.model
sealed class ConvoNavigationIntent {
data object Back : ConvoNavigationIntent()
data class MessagesHistory(val convoId: Long) : ConvoNavigationIntent()
data object CreateChat : ConvoNavigationIntent()
data object Archive : ConvoNavigationIntent()
}
@@ -1,6 +1,10 @@
package dev.meloda.fast.convos.model
import androidx.compose.runtime.Immutable
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.model.vk.UiConvo
import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.emptyImmutableList
@Immutable
data class ConvosScreenState(
@@ -10,7 +14,13 @@ data class ConvosScreenState(
val profileImageUrl: String?,
val scrollIndex: Int,
val scrollOffset: Int,
val isArchive: Boolean
val canPaginate: Boolean,
val expandedConvoId: Long?,
val convos: ImmutableList<UiConvo>,
val dialog: ConvoDialog?,
// TODO: 30.05.2026, Danil Nikolaev: remove
val error: BaseError?
) {
companion object {
@@ -21,7 +31,11 @@ data class ConvosScreenState(
profileImageUrl = null,
scrollIndex = 0,
scrollOffset = 0,
isArchive = false
canPaginate = false,
expandedConvoId = null,
convos = emptyImmutableList(),
dialog = null,
error = null
)
}
}
@@ -1,12 +1,16 @@
package dev.meloda.fast.convos.navigation
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.navigation
import dev.meloda.fast.convos.ConvosViewModel
import dev.meloda.fast.convos.model.ConvoNavigationIntent
import dev.meloda.fast.convos.presentation.ConvosRoute
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.ConvosFilter
import dev.meloda.fast.ui.extensions.getOrThrow
import dev.meloda.fast.ui.theme.LocalNavController
@@ -24,44 +28,56 @@ object Convos
object Archive
fun NavGraphBuilder.convosGraph(
handleNavigationIntent: (ConvoNavigationIntent) -> Unit,
activity: AppCompatActivity,
onError: (BaseError) -> Unit,
onNavigateToMessagesHistory: (id: Long) -> Unit,
onNavigateToCreateChat: () -> Unit,
onScrolledToTop: () -> Unit
) {
navigation<ConvoGraph>(
startDestination = Convos
) {
val convosViewModel: ConvosViewModel = with(activity) {
getViewModel(qualifier = named(ConvosFilter.ALL))
}
composable<Convos> {
val navController = LocalNavController.getOrThrow()
ConvosRoute(
viewModel = convosViewModel,
onError = onError,
onNavigateToMessagesHistory = onNavigateToMessagesHistory,
onNavigateToCreateChat = onNavigateToCreateChat,
onNavigateToArchive = { navController.navigate(Archive) },
onScrolledToTop = onScrolledToTop
ConvosRootRoute(
handleNavigationIntent = handleNavigationIntent,
viewModel = with(activity) {
getViewModel(named(ConvosFilter.ALL))
}
)
}
composable<Archive> {
val navController = LocalNavController.getOrThrow()
ConvosRoute(
ConvosRootRoute(
handleNavigationIntent = handleNavigationIntent,
viewModel = with(activity) {
getViewModel<ConvosViewModel>(
qualifier = named(ConvosFilter.ARCHIVE)
)
},
onBack = navController::navigateUp,
onError = onError,
onNavigateToMessagesHistory = onNavigateToMessagesHistory,
onScrolledToTop = onScrolledToTop
getViewModel<ConvosViewModel>(named(ConvosFilter.ARCHIVE))
}
)
}
}
}
@Composable
private fun ConvosRootRoute(
handleNavigationIntent: (ConvoNavigationIntent) -> Unit,
viewModel: ConvosViewModel
) {
val navController = LocalNavController.getOrThrow()
val screenState by viewModel.screenStateFlow.collectAsStateWithLifecycle()
val navigationIntent by viewModel.navigationIntentFlow.collectAsStateWithLifecycle()
LaunchedEffect(navigationIntent) {
navigationIntent?.let {
when (navigationIntent) {
ConvoNavigationIntent.Back -> navController.navigateUp()
ConvoNavigationIntent.Archive -> navController.navigate(Archive)
else -> handleNavigationIntent(it)
}
viewModel.onNavigationConsumed()
}
}
ConvosRoute(
handleIntent = viewModel::handleIntent,
screenState = screenState,
isArchive = viewModel.filter == ConvosFilter.ARCHIVE,
)
}
@@ -1,82 +1,78 @@
package dev.meloda.fast.convos.presentation
import android.os.Bundle
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.core.os.bundleOf
import dev.meloda.fast.convos.model.ConvoDialog
import dev.meloda.fast.convos.model.ConvoIntent
import dev.meloda.fast.convos.model.ConvosScreenState
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.MaterialDialog
@Composable
fun HandleDialogs(
handleIntent: (ConvoIntent) -> Unit,
screenState: ConvosScreenState,
dialog: ConvoDialog?,
onConfirmed: (ConvoDialog, Bundle) -> Unit = { _, _ -> },
onDismissed: (ConvoDialog) -> Unit = {},
onItemPicked: (ConvoDialog, Bundle) -> Unit = { _, _ -> }
) {
when (dialog) {
when (screenState.dialog) {
null -> Unit
is ConvoDialog.ConvoArchive -> {
is ConvoDialog.Archive -> {
MaterialDialog(
onDismissRequest = { onDismissed(dialog) },
onDismissRequest = { handleIntent(ConvoIntent.Dialog.Dismiss) },
title = stringResource(id = R.string.confirm_archive_convo),
confirmAction = { onConfirmed(dialog, bundleOf()) },
confirmAction = { handleIntent(ConvoIntent.Dialog.Confirm()) },
confirmText = stringResource(id = R.string.action_archive),
cancelText = stringResource(id = R.string.cancel),
icon = ImageVector.vectorResource(R.drawable.ic_archive_fill_round_24)
)
}
is ConvoDialog.ConvoUnarchive -> {
is ConvoDialog.Unarchive -> {
MaterialDialog(
onDismissRequest = { onDismissed(dialog) },
onDismissRequest = { handleIntent(ConvoIntent.Dialog.Dismiss) },
title = stringResource(id = R.string.confirm_unarchive_convo),
confirmAction = { onConfirmed(dialog, bundleOf()) },
confirmAction = { handleIntent(ConvoIntent.Dialog.Confirm()) },
confirmText = stringResource(id = R.string.action_unarchive),
cancelText = stringResource(id = R.string.cancel),
icon = ImageVector.vectorResource(R.drawable.ic_unarchive_fill_round_24)
)
}
is ConvoDialog.ConvoDelete -> {
is ConvoDialog.Delete -> {
val errorColor = MaterialTheme.colorScheme.error
MaterialDialog(
onDismissRequest = { onDismissed(dialog) },
onDismissRequest = { handleIntent(ConvoIntent.Dialog.Dismiss) },
icon = ImageVector.vectorResource(R.drawable.ic_delete_fill_round_24),
iconTint = errorColor,
title = stringResource(id = R.string.confirm_delete_convo),
confirmAction = { onConfirmed(dialog, bundleOf()) },
confirmAction = { handleIntent(ConvoIntent.Dialog.Confirm()) },
confirmText = stringResource(id = R.string.action_delete),
confirmContainerColor = errorColor,
cancelText = stringResource(id = R.string.cancel),
)
}
is ConvoDialog.ConvoPin -> {
is ConvoDialog.Pin -> {
MaterialDialog(
onDismissRequest = { onDismissed(dialog) },
onDismissRequest = { handleIntent(ConvoIntent.Dialog.Dismiss) },
icon = ImageVector.vectorResource(R.drawable.ic_keep_fill_round_24),
title = stringResource(id = R.string.confirm_pin_convo),
confirmAction = { onConfirmed(dialog, bundleOf()) },
confirmAction = { handleIntent(ConvoIntent.Dialog.Confirm()) },
confirmText = stringResource(id = R.string.action_pin),
cancelText = stringResource(id = R.string.cancel),
)
}
is ConvoDialog.ConvoUnpin -> {
is ConvoDialog.Unpin -> {
MaterialDialog(
onDismissRequest = { onDismissed(dialog) },
onDismissRequest = { handleIntent(ConvoIntent.Dialog.Dismiss) },
icon = ImageVector.vectorResource(R.drawable.ic_keep_off_fill_round_24),
title = stringResource(id = R.string.confirm_unpin_convo),
confirmAction = { onConfirmed(dialog, bundleOf()) },
confirmAction = { handleIntent(ConvoIntent.Dialog.Confirm()) },
confirmText = stringResource(id = R.string.action_unpin),
cancelText = stringResource(id = R.string.cancel),
)
@@ -62,9 +62,9 @@ val BirthdayColor = Color(0xffb00b69)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ConvoItem(
onItemClick: (UiConvo) -> Unit,
onItemLongClick: (convo: UiConvo) -> Unit,
onOptionClicked: (UiConvo, ConvoOption) -> Unit,
onItemClick: (convoId: Long) -> Unit,
onItemLongClick: (convoId: Long) -> Unit,
onOptionClicked: (ConvoOption) -> Unit,
maxLines: Int,
isUserAccount: Boolean,
convo: UiConvo,
@@ -81,9 +81,9 @@ fun ConvoItem(
modifier = modifier
.fillMaxWidth()
.combinedClickable(
onClick = { onItemClick(convo) },
onClick = { onItemClick(convo.id) },
onLongClick = {
onItemLongClick(convo)
onItemLongClick(convo.id)
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
)
@@ -281,7 +281,7 @@ fun ConvoItem(
val builder =
AnnotatedString.Builder(convo.message.text)
convo.message.spanStyles.map { spanStyleRange ->
convo.message.spanStyles.forEach { spanStyleRange ->
val updatedSpanStyle =
if (spanStyleRange.item.color == Color.Red) {
spanStyleRange.item.copy(color = MaterialTheme.colorScheme.primary)
@@ -378,7 +378,7 @@ fun ConvoItem(
}
ElevatedAssistChip(
onClick = { onOptionClicked(convo, option) },
onClick = { onOptionClicked(option) },
leadingIcon = {
option.icon.getResourcePainter()?.let { painter ->
Icon(
@@ -36,12 +36,12 @@ import kotlinx.coroutines.launch
fun ConvosList(
modifier: Modifier = Modifier,
convos: ImmutableList<UiConvo>,
onConvosClick: (UiConvo) -> Unit,
onConvosLongClick: (UiConvo) -> Unit,
onConvosClick: (Long) -> Unit,
onConvosLongClick: (Long) -> Unit,
screenState: ConvosScreenState,
state: LazyListState,
maxLines: Int,
onOptionClicked: (UiConvo, ConvoOption) -> Unit,
onOptionClicked: (ConvoOption) -> Unit,
padding: PaddingValues
) {
val theme = LocalThemeConfig.current
@@ -1,79 +1,23 @@
package dev.meloda.fast.convos.presentation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.meloda.fast.convos.ConvosViewModel
import dev.meloda.fast.convos.model.ConvoNavigation
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
import dev.meloda.fast.convos.model.ConvoIntent
import dev.meloda.fast.convos.model.ConvosScreenState
@Composable
fun ConvosRoute(
viewModel: ConvosViewModel,
onBack: (() -> Unit)? = null,
onError: (BaseError) -> Unit,
onNavigateToMessagesHistory: (convoId: Long) -> Unit,
onNavigateToCreateChat: (() -> Unit)? = null,
onNavigateToArchive: (() -> Unit)? = null,
onScrolledToTop: () -> Unit,
handleIntent: (ConvoIntent) -> Unit,
screenState: ConvosScreenState,
isArchive: Boolean,
) {
val screenState by viewModel.screenStateFlow.collectAsStateWithLifecycle()
val navigationEvent by viewModel.navigationFlow.collectAsStateWithLifecycle()
val convos by viewModel.uiConvosFlow.collectAsStateWithLifecycle()
val dialog by viewModel.dialogFlow.collectAsStateWithLifecycle()
val baseError by viewModel.baseErrorFlow.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginateFlow.collectAsStateWithLifecycle()
LaunchedEffect(navigationEvent) {
val shouldBeConsumed: Boolean = when (val navigation = navigationEvent) {
null -> false
is ConvoNavigation.CreateChat -> {
onNavigateToCreateChat?.invoke()
true
}
is ConvoNavigation.MessagesHistory -> {
onNavigateToMessagesHistory(navigation.peerId)
true
}
}
if (shouldBeConsumed) viewModel.onNavigationConsumed()
}
ConvosScreen(
onBack = { onBack?.invoke() },
handleIntent = handleIntent,
screenState = screenState,
convos = convos.toImmutableList(),
baseError = baseError,
canPaginate = canPaginate,
onConvoItemClicked = viewModel::onConvoItemClick,
onConvoItemLongClicked = viewModel::onConvoItemLongClick,
onOptionClicked = viewModel::onOptionClicked,
onPaginationConditionsMet = viewModel::onPaginationConditionsMet,
onRefresh = viewModel::onRefresh,
onCreateChatButtonClicked = viewModel::onCreateChatButtonClicked,
onArchiveActionClicked = { onNavigateToArchive?.invoke() },
setScrollIndex = viewModel::setScrollIndex,
setScrollOffset = viewModel::setScrollOffset,
onConsumeReselection = onScrolledToTop,
onErrorViewButtonClicked = {
if (baseError in listOf(BaseError.AccountBlocked, BaseError.SessionExpired)) {
onError(requireNotNull(baseError))
} else {
viewModel.onErrorButtonClicked()
}
}
isArchive = isArchive,
)
HandleDialogs(
handleIntent = handleIntent,
screenState = screenState,
dialog = dialog,
onConfirmed = viewModel::onDialogConfirmed,
onDismissed = viewModel::onDialogDismissed,
onItemPicked = viewModel::onDialogItemPicked
)
}
@@ -56,25 +56,20 @@ import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.convos.model.ConvoIntent
import dev.meloda.fast.convos.model.ConvosScreenState
import dev.meloda.fast.convos.navigation.ConvoGraph
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.FullScreenContainedLoader
import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.components.SegmentedButtonItem
import dev.meloda.fast.ui.components.SegmentedButtonsRow
import dev.meloda.fast.ui.components.VkErrorView
import dev.meloda.fast.ui.model.vk.ConvoOption
import dev.meloda.fast.ui.model.vk.UiConvo
import dev.meloda.fast.ui.theme.LocalBottomPadding
import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalReselectedTab
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.buildImmutableList
import dev.meloda.fast.ui.util.emptyImmutableList
import dev.meloda.fast.ui.util.isScrollingUp
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
@@ -82,26 +77,14 @@ import kotlin.time.Duration.Companion.milliseconds
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalHazeMaterialsApi::class, ExperimentalMaterial3ExpressiveApi::class,
ExperimentalHazeMaterialsApi::class,
ExperimentalMaterial3ExpressiveApi::class,
)
@Composable
fun ConvosScreen(
screenState: ConvosScreenState = ConvosScreenState.EMPTY,
convos: ImmutableList<UiConvo> = emptyImmutableList(),
baseError: BaseError? = null,
canPaginate: Boolean = false,
onBack: () -> Unit = {},
onConvoItemClicked: (convo: UiConvo) -> Unit = {},
onConvoItemLongClicked: (convo: UiConvo) -> Unit = {},
onOptionClicked: (UiConvo, ConvoOption) -> Unit = { _, _ -> },
onPaginationConditionsMet: () -> Unit = {},
onRefresh: () -> Unit = {},
onCreateChatButtonClicked: () -> Unit = {},
onArchiveActionClicked: () -> Unit = {},
setScrollIndex: (Int) -> Unit = {},
setScrollOffset: (Int) -> Unit = {},
onConsumeReselection: () -> Unit = {},
onErrorViewButtonClicked: () -> Unit = {}
handleIntent: (ConvoIntent) -> Unit,
screenState: ConvosScreenState,
isArchive: Boolean,
) {
val currentTheme = LocalThemeConfig.current
val maxLines = if (currentTheme.enableMultiline) 2 else 1
@@ -114,14 +97,14 @@ fun ConvosScreen(
val currentTabReselected = LocalReselectedTab.current[ConvoGraph] == true
LaunchedEffect(currentTabReselected) {
if (currentTabReselected) {
if (screenState.isArchive) {
onBack.invoke()
if (isArchive) {
handleIntent(ConvoIntent.Back)
} else {
if (listState.firstVisibleItemIndex > 14) {
listState.scrollToItem(14)
}
listState.animateScrollToItem(0)
onConsumeReselection()
handleIntent(ConvoIntent.ConsumeScrollToTop)
}
}
}
@@ -129,18 +112,18 @@ fun ConvosScreen(
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.debounce(500L.milliseconds)
.collectLatest(setScrollIndex)
.collectLatest { handleIntent(ConvoIntent.SetScrollIndex(it)) }
}
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemScrollOffset }
.debounce(500L.milliseconds)
.collectLatest(setScrollOffset)
.collectLatest { handleIntent(ConvoIntent.SetScrollOffset(it)) }
}
val paginationConditionMet by remember(canPaginate, listState) {
val paginationConditionMet by remember(screenState.canPaginate, listState) {
derivedStateOf {
canPaginate &&
screenState.canPaginate &&
(listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index
?: -9) >= (listState.layoutInfo.totalItemsCount - 6)
}
@@ -148,7 +131,7 @@ fun ConvosScreen(
LaunchedEffect(paginationConditionMet) {
if (paginationConditionMet && !screenState.isPaginating) {
onPaginationConditionsMet()
handleIntent(ConvoIntent.PaginationConditionsMet)
}
}
@@ -183,7 +166,7 @@ fun ConvosScreen(
text = stringResource(
id = when {
screenState.isLoading -> R.string.title_loading
screenState.isArchive -> R.string.title_archive
isArchive -> R.string.title_archive
else -> R.string.title_convos
}
),
@@ -193,8 +176,8 @@ fun ConvosScreen(
)
},
navigationIcon = {
if (screenState.isArchive) {
IconButton(onClick = onBack) {
if (isArchive) {
IconButton(onClick = { handleIntent(ConvoIntent.Back) }) {
Icon(
painter = painterResource(R.drawable.ic_arrow_back_round_24),
contentDescription = null
@@ -206,7 +189,7 @@ fun ConvosScreen(
val dropDownItems: List<@Composable () -> Unit> = buildList {}
val items = buildImmutableList {
if (!screenState.isArchive) {
if (!isArchive) {
add(SegmentedButtonItem("archive", R.drawable.ic_archive_round_24))
}
@@ -225,8 +208,8 @@ fun ConvosScreen(
items = items,
onClick = { index ->
when (items[index].key) {
"archive" -> onArchiveActionClicked()
"refresh" -> onRefresh()
"archive" -> handleIntent(ConvoIntent.ArchiveClick)
"refresh" -> handleIntent(ConvoIntent.Refresh)
"more" -> dropDownMenuExpanded = true
else -> Unit
@@ -263,7 +246,7 @@ fun ConvosScreen(
)
val showHorizontalProgressBar by remember(screenState) {
derivedStateOf { screenState.isLoading && convos.isNotEmpty() }
derivedStateOf { screenState.isLoading && screenState.convos.isNotEmpty() }
}
AnimatedVisibility(showHorizontalProgressBar) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
@@ -274,14 +257,14 @@ fun ConvosScreen(
}
},
floatingActionButton = {
if (!screenState.isArchive) {
if (!isArchive) {
val offsetY by animateIntAsState(
targetValue = if (listState.isScrollingUp()) 0 else 600
)
Column {
FloatingActionButton(
onClick = onCreateChatButtonClicked,
onClick = { handleIntent(ConvoIntent.CreateChatClick) },
modifier = Modifier.offset {
IntOffset(0, offsetY)
}
@@ -298,14 +281,15 @@ fun ConvosScreen(
}
) { padding ->
when {
baseError != null -> {
VkErrorView(
baseError = baseError,
onButtonClick = onErrorViewButtonClicked
)
}
// TODO: 30.05.2026, Danil Nikolaev: move to UI State
// baseError != null -> {
// VkErrorView(
// baseError = baseError,
// onButtonClick = onErrorViewButtonClicked
// )
// }
screenState.isLoading && convos.isEmpty() -> FullScreenContainedLoader()
screenState.isLoading && screenState.convos.isEmpty() -> FullScreenContainedLoader()
else -> {
val pullToRefreshState = rememberPullToRefreshState()
@@ -318,7 +302,7 @@ fun ConvosScreen(
.padding(bottom = padding.calculateBottomPadding()),
state = pullToRefreshState,
isRefreshing = screenState.isLoading,
onRefresh = onRefresh,
onRefresh = { handleIntent(ConvoIntent.Refresh) },
indicator = {
PullToRefreshDefaults.Indicator(
state = pullToRefreshState,
@@ -330,9 +314,9 @@ fun ConvosScreen(
}
) {
ConvosList(
convos = convos,
onConvosClick = onConvoItemClicked,
onConvosLongClick = onConvoItemLongClicked,
convos = screenState.convos,
onConvosClick = { handleIntent(ConvoIntent.ItemClick(it)) },
onConvosLongClick = { handleIntent(ConvoIntent.ItemLongClick(it)) },
screenState = screenState,
state = listState,
maxLines = maxLines,
@@ -341,14 +325,14 @@ fun ConvosScreen(
} else {
Modifier
}.fillMaxSize(),
onOptionClicked = onOptionClicked,
onOptionClicked = { handleIntent(ConvoIntent.OptionItemClick(it)) },
padding = padding
)
if (convos.isEmpty()) {
if (screenState.convos.isEmpty()) {
NoItemsView(
buttonText = stringResource(R.string.action_refresh),
onButtonClick = onRefresh
onButtonClick = { handleIntent(ConvoIntent.Refresh) }
)
}
}