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:
@@ -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) {
|
||||
|
||||
+2
@@ -65,4 +65,6 @@ interface MessagesHistoryViewModel {
|
||||
fun onReplyCloseClicked()
|
||||
|
||||
fun onRequestReplyToMessage(cmId: Long)
|
||||
|
||||
suspend fun loadMessageReadPeers(peerId: Long, cmId: Long): Int
|
||||
}
|
||||
|
||||
+21
@@ -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
|
||||
|
||||
|
||||
+9
-15
@@ -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(
|
||||
|
||||
+74
-30
@@ -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>,
|
||||
|
||||
+1
@@ -85,6 +85,7 @@ fun MessagesHistoryRoute(
|
||||
HandleDialogs(
|
||||
screenState = screenState,
|
||||
dialog = dialog,
|
||||
messageReadPeersLoader = viewModel::loadMessageReadPeers,
|
||||
onConfirmed = viewModel::onDialogConfirmed,
|
||||
onDismissed = viewModel::onDialogDismissed,
|
||||
onItemPicked = viewModel::onDialogItemPicked
|
||||
|
||||
+35
-40
@@ -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(
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user