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
@@ -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,25 +93,21 @@ fun MessageBubble(
mutableIntStateOf(0)
}
val shouldFill by remember(bubbleContainerWidth, attachmentsContainerWidth) {
derivedStateOf {
attachmentsContainerWidth >= bubbleContainerWidth
}
val shouldFill by derivedStateOf {
attachmentsContainerWidth >= bubbleContainerWidth
}
var containerWidth by remember {
mutableIntStateOf(0)
}
val minDateContainerWidth by remember(isEdited, isOut, isPinned, isImportant) {
derivedStateOf {
val mainPart = if (isEdited) 50 else 30
val readIndicatorPart = if (isOut) 14 else 0
val pinnedIndicatorPart = if (isPinned) 14 else 0
val importantIndicatorPart = if (isImportant) 14 else 0
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
val importantIndicatorPart = if (isImportant) 14 else 0
(mainPart + readIndicatorPart + pinnedIndicatorPart + importantIndicatorPart).dp
}
(mainPart + readIndicatorPart + pinnedIndicatorPart + importantIndicatorPart).dp
}
val dateContainerWidth by animateDpAsState(
@@ -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 ->
Triple(
stringResource(option.titleResId),
painterResource(option.iconResId),
when {
option in listOf(
MessageOption.Delete,
MessageOption.MarkAsSpam
) -> MaterialTheme.colorScheme.error
val messageOptions = remember(options) {
options.map { option ->
Triple(
option.titleResId,
option.iconResId,
when {
option in listOf(
MessageOption.Delete,
MessageOption.MarkAsSpam
) -> errorColor
else -> MaterialTheme.colorScheme.primary
}
)
else -> primaryColor
}
)
}
}
MaterialDialog(onDismissRequest = onDismissed) {
if (showReadPeers) {
var viewCount by remember {
mutableStateOf<Int?>(null)
}
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, (title, painter, tintColor) ->
DropdownMenuItem(
text = {
Row {
Text(text = title)
Spacer(modifier = Modifier.width(8.dp))
}
},
leadingIcon = {
Row {
Spacer(modifier = Modifier.width(8.dp))
Icon(
painter = painter,
contentDescription = null,
tint = tintColor
)
}
},
.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,22 +78,21 @@ fun MessagesList(
val scope = rememberCoroutineScope()
val onAttachmentClick by rememberUpdatedState(
{ message: UiItem.Message, attachment: VkAttachment ->
if (isSelectedAtLeastOne) {
onMessageClicked(message.id)
} else {
when (attachment) {
is VkPhotoDomain -> {
val photos = message.attachments
.orEmpty()
.filterIsInstance<VkPhotoDomain>()
.mapNotNull { photo -> photo.getMaxSize()?.url }
val onAttachmentClick by rememberUpdatedState { message: UiItem.Message, attachment: VkAttachment ->
if (isSelectedAtLeastOne) {
onMessageClicked(message.id)
} else {
when (attachment) {
is VkPhotoDomain -> {
val photos = message.attachments
.orEmpty()
.filterIsInstance<VkPhotoDomain>()
.mapNotNull { photo -> photo.getMaxSize()?.url }
onPhotoClicked(
photos,
photos.indexOfFirst { it == attachment.getMaxSize()?.url }
)
onPhotoClicked(
photos,
photos.indexOfFirst { it == attachment.getMaxSize()?.url }
)
// val maxSize = attachment.getMaxSize()
// maxSize?.let {
@@ -101,39 +100,36 @@ fun MessagesList(
// Intent(Intent.ACTION_VIEW, maxSize.url.toUri())
// )
// }
}
}
is VkFileDomain -> {
context.startActivity(
Intent(Intent.ACTION_VIEW, attachment.url.toUri())
)
}
is VkFileDomain -> {
context.startActivity(
Intent(Intent.ACTION_VIEW, attachment.url.toUri())
)
}
is VkLinkDomain -> {
context.startActivity(
Intent(Intent.ACTION_VIEW, attachment.url.toUri())
)
}
is VkLinkDomain -> {
context.startActivity(
Intent(Intent.ACTION_VIEW, attachment.url.toUri())
)
}
}
}
)
}
val onAttachmentLongClick by rememberUpdatedState(
{ message: UiItem.Message, attachment: VkAttachment ->
if (isSelectedAtLeastOne) {
onMessageLongClicked(message.id)
uiMessages
} else {
when (attachment) {
is VkPhotoDomain -> {
val maxSize = attachment.getMaxSize()
Log.d("MessagesList", "onPhotoLongClicked. Max size: ${maxSize?.url}")
}
val onAttachmentLongClick by rememberUpdatedState { message: UiItem.Message, attachment: VkAttachment ->
if (isSelectedAtLeastOne) {
onMessageLongClicked(message.id)
uiMessages
} else {
when (attachment) {
is VkPhotoDomain -> {
val maxSize = attachment.getMaxSize()
Log.d("MessagesList", "onPhotoLongClicked. Max size: ${maxSize?.url}")
}
}
}
)
}
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