feat(messages): Implement "message read by" counter

This commit introduces the ability to see how many people have read an outgoing message in a group chat. A "views" count is now displayed in the message options dialog for relevant messages.

- **API & Data Layer:**
  - Added `getMessageReadPeers` to `MessagesService` and `MessagesRepository` to fetch users who have read a specific message.
  - Introduced `MessagesGetReadPeersResponse` to handle the API response.
  - A new URL constant `GET_MESSAGE_READ_PEERS` was added.

- **Domain Layer:**
  - A new `GetMessageReadPeersUseCase` is created to provide the view count to the ViewModel.
  - The use case is registered in the `DomainModule`.

- **ViewModel:**
  - `MessagesHistoryViewModel` now includes `loadMessageReadPeers` to asynchronously fetch and return the view count for a message.

- **UI (Compose):**
  - The `MessageOptionsDialog` now displays a "views" count for outgoing chat messages.
  - It uses a `LaunchedEffect` to call `loadMessageReadPeers` when the dialog is shown.
  - The `visibility` icon has been updated and its XML file renamed to `round_visibility_24px.xml` to follow a consistent naming convention.

- **Refactoring & Minor Fixes:**
  - Simplified several `derivedStateOf` usages to direct calculations or property delegates in `MessageBubble.kt` and `MessagesList.kt` for minor performance improvements.
  - Renamed `IncomingMessageBubble.kt` and `OutgoingMessageBubble.kt` to `MessageBubbleIncoming.kt` and `MessageBubbleOutgoing.kt` respectively for consistency.
  - Removed an unnecessary log statement in `MessagesList.kt`.
This commit is contained in:
2025-12-06 14:37:08 +03:00
parent 5310596cf6
commit 7b2c102470
19 changed files with 216 additions and 98 deletions
@@ -6,6 +6,7 @@ import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.model.api.responses.MessagesGetConversationMembersResponse
import dev.meloda.fast.model.api.responses.MessagesGetReadPeersResponse
import dev.meloda.fast.model.api.responses.MessagesSendResponse
import dev.meloda.fast.network.RestApiErrorDomain
@@ -110,4 +111,9 @@ interface MessagesRepository {
chatId: Long,
memberId: Long
): ApiResult<Int, RestApiErrorDomain>
suspend fun getMessageReadPeers(
peerId: Long,
cmId: Long
): ApiResult<MessagesGetReadPeersResponse, RestApiErrorDomain>
}
@@ -37,6 +37,7 @@ import dev.meloda.fast.model.api.requests.MessagesRemoveChatUserRequest
import dev.meloda.fast.model.api.requests.MessagesSendRequest
import dev.meloda.fast.model.api.requests.MessagesUnpinMessageRequest
import dev.meloda.fast.model.api.responses.MessagesGetConversationMembersResponse
import dev.meloda.fast.model.api.responses.MessagesGetReadPeersResponse
import dev.meloda.fast.model.api.responses.MessagesSendResponse
import dev.meloda.fast.network.RestApiErrorDomain
import dev.meloda.fast.network.mapApiDefault
@@ -419,4 +420,18 @@ class MessagesRepositoryImpl(
messagesService.removeChatUser(requestModel.map).mapApiDefault()
}
override suspend fun getMessageReadPeers(
peerId: Long,
cmId: Long
): ApiResult<MessagesGetReadPeersResponse, RestApiErrorDomain> = withContext(Dispatchers.IO) {
messagesService.getMessageReadPeers(
mapOf(
"peer_id" to peerId.toString(),
"cmid" to cmId.toString(),
"extended" to "1",
"fields" to VkConstants.USER_FIELDS
)
).mapApiDefault()
}
}
@@ -0,0 +1,21 @@
package dev.meloda.fast.domain
import dev.meloda.fast.data.State
import dev.meloda.fast.data.api.messages.MessagesRepository
import dev.meloda.fast.data.mapToState
import kotlinx.coroutines.flow.Flow
class GetMessageReadPeersUseCase(
private val repository: MessagesRepository
) : BaseUseCase {
operator fun invoke(
peerId: Long,
cmId: Long
): Flow<State<Int>> = flowNewState {
repository.getMessageReadPeers(
peerId = peerId,
cmId = cmId
).mapToState(successMapper = { it.totalCount })
}
}
@@ -6,6 +6,7 @@ import dev.meloda.fast.domain.AccountUseCaseImpl
import dev.meloda.fast.domain.GetCurrentAccountUseCase
import dev.meloda.fast.domain.GetLocalUserByIdUseCase
import dev.meloda.fast.domain.GetLocalUsersByIdsUseCase
import dev.meloda.fast.domain.GetMessageReadPeersUseCase
import dev.meloda.fast.domain.LoadConversationsByIdUseCase
import dev.meloda.fast.domain.LoadUserByIdUseCase
import dev.meloda.fast.domain.LoadUsersByIdsUseCase
@@ -27,4 +28,6 @@ val domainModule = module {
singleOf(::GetCurrentAccountUseCase)
singleOf(::LoadConversationsByIdUseCase)
singleOf(::GetMessageReadPeersUseCase)
}
@@ -70,3 +70,10 @@ data class MessagesMarkAsImportantResponse(
@Json(name = "peer_id") val peerId: Long
)
}
@JsonClass(generateAdapter = true)
data class MessagesGetReadPeersResponse(
@Json(name = "items") val items: List<Long>,
@Json(name = "total_count") val totalCount: Int,
@Json(name = "profiles") val profiles: List<VkUserData>?,
)
@@ -9,6 +9,7 @@ import dev.meloda.fast.model.api.responses.MessagesGetByIdResponse
import dev.meloda.fast.model.api.responses.MessagesGetConversationMembersResponse
import dev.meloda.fast.model.api.responses.MessagesGetHistoryAttachmentsResponse
import dev.meloda.fast.model.api.responses.MessagesGetHistoryResponse
import dev.meloda.fast.model.api.responses.MessagesGetReadPeersResponse
import dev.meloda.fast.model.api.responses.MessagesMarkAsImportantResponse
import dev.meloda.fast.model.api.responses.MessagesSendResponse
import dev.meloda.fast.network.ApiResponse
@@ -108,4 +109,10 @@ interface MessagesService {
suspend fun removeChatUser(
@FieldMap params: Map<String, String>
): ApiResult<ApiResponse<Int>, RestApiError>
@FormUrlEncoded
@POST(MessagesUrls.GET_MESSAGE_READ_PEERS)
suspend fun getMessageReadPeers(
@FieldMap params: Map<String, String>
): ApiResult<ApiResponse<MessagesGetReadPeersResponse>, RestApiError>
}
@@ -22,4 +22,5 @@ object MessagesUrls {
const val REMOVE_CHAT_USER = "$URL/messages.removeChatUser"
const val GET_HISTORY_ATTACHMENTS = "$URL/messages.getHistoryAttachments"
const val CREATE_CHAT = "$URL/messages.createChat"
const val GET_MESSAGE_READ_PEERS = "$URL/messages.getMessageReadPeers"
}
@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12,4C7,4 2.73,7.11 1,11.5 2.73,15.89 7,19 12,19s9.27,-3.11 11,-7.5C21.27,7.11 17,4 12,4zM12,16.5c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5zM12,8.5c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3 -1.34,-3 -3,-3z" />
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#ffffff"
android:pathData="M480,640Q555,640 607.5,587.5Q660,535 660,460Q660,385 607.5,332.5Q555,280 480,280Q405,280 352.5,332.5Q300,385 300,460Q300,535 352.5,587.5Q405,640 480,640ZM480,568Q435,568 403.5,536.5Q372,505 372,460Q372,415 403.5,383.5Q435,352 480,352Q525,352 556.5,383.5Q588,415 588,460Q588,505 556.5,536.5Q525,568 480,568ZM480,760Q334,760 214,678.5Q94,597 40,460Q94,323 214,241.5Q334,160 480,160Q626,160 746,241.5Q866,323 920,460Q866,597 746,678.5Q626,760 480,760ZM480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460ZM480,680Q593,680 687.5,620.5Q782,561 832,460Q782,359 687.5,299.5Q593,240 480,240Q367,240 272.5,299.5Q178,359 128,460Q178,561 272.5,620.5Q367,680 480,680Z" />
</vector>
@@ -296,7 +296,7 @@ fun LoginScreen(
trailingIcon = {
val imagePainter = painterResource(
id = if (screenState.passwordVisible) R.drawable.round_visibility_off_24
else R.drawable.round_visibility_24
else R.drawable.round_visibility_24px
)
IconButton(onClick = onPasswordVisibilityButtonClicked) {
@@ -65,4 +65,6 @@ interface MessagesHistoryViewModel {
fun onReplyCloseClicked()
fun onRequestReplyToMessage(cmId: Long)
suspend fun loadMessageReadPeers(peerId: Long, cmId: Long): Int
}
@@ -38,6 +38,7 @@ import dev.meloda.fast.data.processState
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.domain.ConversationsUseCase
import dev.meloda.fast.domain.GetMessageReadPeersUseCase
import dev.meloda.fast.domain.LoadConversationsByIdUseCase
import dev.meloda.fast.domain.LongPollUpdatesParser
import dev.meloda.fast.domain.MessagesUseCase
@@ -70,6 +71,8 @@ import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import java.io.File
import java.io.FileOutputStream
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import kotlin.math.abs
import kotlin.random.Random
@@ -80,6 +83,7 @@ class MessagesHistoryViewModelImpl(
private val resourceProvider: ResourceProvider,
private val userSettings: UserSettings,
private val loadConversationsByIdUseCase: LoadConversationsByIdUseCase,
private val getMessageReadPeersUseCase: GetMessageReadPeersUseCase,
updatesParser: LongPollUpdatesParser,
savedStateHandle: SavedStateHandle
) : MessagesHistoryViewModel, ViewModel() {
@@ -575,6 +579,23 @@ class MessagesHistoryViewModelImpl(
replyToMessage(cmId)
}
override suspend fun loadMessageReadPeers(peerId: Long, cmId: Long): Int = suspendCoroutine {
viewModelScope.launch {
getMessageReadPeersUseCase
.invoke(peerId = peerId, cmId = cmId)
.listenValue(viewModelScope) { state ->
state.processState(
error = { error ->
it.resume(-1)
},
success = { count ->
it.resume(count)
}
)
}
}
}
private fun handleNewMessage(event: LongPollParsedEvent.NewMessage) {
val message = event.message
@@ -83,9 +83,7 @@ fun MessageBubble(
MaterialTheme.colorScheme.onPrimaryContainer
}
val shouldShowBubble by remember(text) {
derivedStateOf { text != null }
}
val shouldShowBubble = !text.isNullOrEmpty()
var bubbleContainerWidth by remember {
mutableIntStateOf(0)
@@ -95,18 +93,15 @@ fun MessageBubble(
mutableIntStateOf(0)
}
val shouldFill by remember(bubbleContainerWidth, attachmentsContainerWidth) {
derivedStateOf {
val shouldFill by derivedStateOf {
attachmentsContainerWidth >= bubbleContainerWidth
}
}
var containerWidth by remember {
mutableIntStateOf(0)
}
val minDateContainerWidth by remember(isEdited, isOut, isPinned, isImportant) {
derivedStateOf {
val minDateContainerWidth = remember(isEdited, isOut, isPinned, isImportant) {
val mainPart = if (isEdited) 50 else 30
val readIndicatorPart = if (isOut) 14 else 0
val pinnedIndicatorPart = if (isPinned) 14 else 0
@@ -114,7 +109,6 @@ fun MessageBubble(
(mainPart + readIndicatorPart + pinnedIndicatorPart + importantIndicatorPart).dp
}
}
val dateContainerWidth by animateDpAsState(
targetValue = minDateContainerWidth,
@@ -14,11 +14,13 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@@ -38,6 +40,7 @@ import java.util.concurrent.TimeUnit
fun HandleDialogs(
screenState: MessagesHistoryScreenState,
dialog: MessageDialog?,
messageReadPeersLoader: suspend (peerId: Long, cmId: Long) -> Int,
onConfirmed: (MessageDialog, Bundle) -> Unit = { _, _ -> },
onDismissed: (MessageDialog) -> Unit = {},
onItemPicked: (MessageDialog, Bundle) -> Unit = { _, _ -> }
@@ -49,6 +52,7 @@ fun HandleDialogs(
MessageOptionsDialog(
screenState = screenState,
message = dialog.message,
messageReadPeersLoader = messageReadPeersLoader,
onDismissed = { onDismissed(dialog) },
onItemPicked = { bundle -> onItemPicked(dialog, bundle) }
)
@@ -102,9 +106,15 @@ fun HandleDialogs(
fun MessageOptionsDialog(
screenState: MessagesHistoryScreenState,
message: VkMessage,
messageReadPeersLoader: suspend (peerId: Long, cmId: Long) -> Int,
onDismissed: () -> Unit = {},
onItemPicked: (Bundle) -> Unit
) {
val primaryColor = MaterialTheme.colorScheme.primary
val errorColor = MaterialTheme.colorScheme.error
val showReadPeers = message.isOut && message.isPeerChat()
val options = mutableListOf<MessageOption>()
if (message.isFailed()) {
options += MessageOption.Retry
@@ -142,41 +152,47 @@ fun MessageOptionsDialog(
options += MessageOption.Delete
val messageOptions = options.map { option ->
val messageOptions = remember(options) {
options.map { option ->
Triple(
stringResource(option.titleResId),
painterResource(option.iconResId),
option.titleResId,
option.iconResId,
when {
option in listOf(
MessageOption.Delete,
MessageOption.MarkAsSpam
) -> MaterialTheme.colorScheme.error
) -> errorColor
else -> MaterialTheme.colorScheme.primary
else -> primaryColor
}
)
}
}
MaterialDialog(onDismissRequest = onDismissed) {
messageOptions
.forEachIndexed { index, (title, painter, tintColor) ->
DropdownMenuItem(
text = {
Row {
Text(text = title)
Spacer(modifier = Modifier.width(8.dp))
if (showReadPeers) {
var viewCount by remember {
mutableStateOf<Int?>(null)
}
},
leadingIcon = {
Row {
Spacer(modifier = Modifier.width(8.dp))
Icon(
painter = painter,
contentDescription = null,
tint = tintColor
LaunchedEffect(Unit) {
viewCount = messageReadPeersLoader.invoke(message.peerId, message.cmId)
}
MessageOptionItem(
title = viewCount?.let { "$it views" } ?: "...",
iconResId = R.drawable.round_visibility_24px,
tintColor = primaryColor,
onClick = {}
)
}
},
messageOptions
.forEachIndexed { index, (titleResId, iconResId, tintColor) ->
MessageOptionItem(
title = stringResource(titleResId),
iconResId = iconResId,
tintColor = tintColor,
onClick = {
onDismissed()
val pickedOption = options[index]
@@ -193,6 +209,34 @@ fun MessageOptionsDialog(
}
}
@Composable
private fun MessageOptionItem(
title: String,
iconResId: Int,
tintColor: Color,
onClick: () -> Unit
) {
DropdownMenuItem(
text = {
Row {
Text(text = title)
Spacer(modifier = Modifier.width(8.dp))
}
},
leadingIcon = {
Row {
Spacer(modifier = Modifier.width(8.dp))
Icon(
painter = painterResource(iconResId),
contentDescription = null,
tint = tintColor
)
}
},
onClick = onClick
)
}
@Composable
fun MessageDeleteDialog(
messages: List<VkMessage>,
@@ -85,6 +85,7 @@ fun MessagesHistoryRoute(
HandleDialogs(
screenState = screenState,
dialog = dialog,
messageReadPeersLoader = viewModel::loadMessageReadPeers,
onConfirmed = viewModel::onDialogConfirmed,
onDismissed = viewModel::onDialogDismissed,
onItemPicked = viewModel::onDialogItemPicked
@@ -78,8 +78,7 @@ fun MessagesList(
val scope = rememberCoroutineScope()
val onAttachmentClick by rememberUpdatedState(
{ message: UiItem.Message, attachment: VkAttachment ->
val onAttachmentClick by rememberUpdatedState { message: UiItem.Message, attachment: VkAttachment ->
if (isSelectedAtLeastOne) {
onMessageClicked(message.id)
} else {
@@ -117,10 +116,8 @@ fun MessagesList(
}
}
}
)
val onAttachmentLongClick by rememberUpdatedState(
{ message: UiItem.Message, attachment: VkAttachment ->
val onAttachmentLongClick by rememberUpdatedState { message: UiItem.Message, attachment: VkAttachment ->
if (isSelectedAtLeastOne) {
onMessageLongClicked(message.id)
uiMessages
@@ -133,7 +130,6 @@ fun MessagesList(
}
}
}
)
LazyColumn(
modifier = modifier
@@ -207,7 +203,6 @@ fun MessagesList(
if (offsetDistinct == -100f && AppSettings.General.enableHaptic) {
view.performHapticFeedback(HapticFeedbackConstantsCompat.CONTEXT_CLICK)
}
Log.d("MessagesList", "offsetDistinct: $offsetDistinct")
}
Surface(
@@ -28,14 +28,14 @@ import androidx.compose.ui.unit.dp
@Composable
fun Reply(
modifier: Modifier = Modifier,
onClick: () -> Unit,
bottomPadding: Dp,
shape: Shape,
onClick: () -> Unit,
backgroundColor: Color,
innerBackgroundColor: Color,
title: String,
summary: String?
summary: String?,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier