diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index 00c06ac..e119724 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -108,3 +108,12 @@ ### Step 17 - Sprint B / media tests - Added `NetworkMediaRepositoryTest` for successful upload+attach flow. - Added error-path coverage for failed presigned upload handling. + +## 2026-03-09 +### Step 18 - Sprint P0 / 1) Message core completion +- Extended message API/data contracts with `messages/status`, `forward`, and reaction endpoints. +- Added message domain support for forwarded message metadata and attachment waveform payload. +- Implemented repository operations for delivery/read acknowledgements, forward, and reactions. +- Updated Chat ViewModel/UI with forward flow, reaction toggle, and edit/delete-for-all edge-case guards. +- Added automatic delivered/read acknowledgement for latest incoming message in active chat. +- Fixed outgoing message detection by resolving current user id from JWT `sub` claim. diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/db/MessengerDatabase.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/db/MessengerDatabase.kt index 760cfe2..4462dc0 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/db/MessengerDatabase.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/db/MessengerDatabase.kt @@ -16,7 +16,7 @@ import ru.daemonlord.messenger.data.message.local.entity.MessageEntity MessageEntity::class, MessageAttachmentEntity::class, ], - version = 2, + version = 3, exportSchema = false, ) abstract class MessengerDatabase : RoomDatabase() { diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/message/api/MessageApiService.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/message/api/MessageApiService.kt index 95ff57d..68c7c3f 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/message/api/MessageApiService.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/message/api/MessageApiService.kt @@ -8,7 +8,11 @@ import retrofit2.http.PUT import retrofit2.http.Path import retrofit2.http.Query import ru.daemonlord.messenger.data.message.dto.MessageCreateRequestDto +import ru.daemonlord.messenger.data.message.dto.MessageForwardRequestDto import ru.daemonlord.messenger.data.message.dto.MessageReadDto +import ru.daemonlord.messenger.data.message.dto.MessageReactionDto +import ru.daemonlord.messenger.data.message.dto.MessageReactionToggleRequestDto +import ru.daemonlord.messenger.data.message.dto.MessageStatusUpdateRequestDto import ru.daemonlord.messenger.data.message.dto.MessageUpdateRequestDto interface MessageApiService { @@ -35,4 +39,26 @@ interface MessageApiService { @Path("message_id") messageId: Long, @Query("for_all") forAll: Boolean = false, ) + + @POST("/api/v1/messages/status") + suspend fun updateMessageStatus( + @Body request: MessageStatusUpdateRequestDto, + ) + + @POST("/api/v1/messages/{message_id}/forward") + suspend fun forwardMessage( + @Path("message_id") messageId: Long, + @Body request: MessageForwardRequestDto, + ): MessageReadDto + + @GET("/api/v1/messages/{message_id}/reactions") + suspend fun listReactions( + @Path("message_id") messageId: Long, + ): List + + @POST("/api/v1/messages/{message_id}/reactions/toggle") + suspend fun toggleReaction( + @Path("message_id") messageId: Long, + @Body request: MessageReactionToggleRequestDto, + ): List } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/message/dto/MessageDtos.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/message/dto/MessageDtos.kt index 0cd4959..a0ae6c2 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/message/dto/MessageDtos.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/message/dto/MessageDtos.kt @@ -12,10 +12,14 @@ data class MessageReadDto( val senderId: Long, @SerialName("reply_to_message_id") val replyToMessageId: Long? = null, + @SerialName("forwarded_from_message_id") + val forwardedFromMessageId: Long? = null, val type: String, val text: String? = null, @SerialName("delivery_status") val deliveryStatus: String? = null, + @SerialName("attachment_waveform") + val attachmentWaveform: List? = null, @SerialName("created_at") val createdAt: String, @SerialName("updated_at") @@ -38,3 +42,32 @@ data class MessageCreateRequestDto( data class MessageUpdateRequestDto( val text: String, ) + +@Serializable +data class MessageStatusUpdateRequestDto( + @SerialName("chat_id") + val chatId: Long, + @SerialName("message_id") + val messageId: Long, + val status: String, +) + +@Serializable +data class MessageForwardRequestDto( + @SerialName("target_chat_id") + val targetChatId: Long, + @SerialName("include_author") + val includeAuthor: Boolean = true, +) + +@Serializable +data class MessageReactionDto( + val emoji: String, + val count: Int, + val reacted: Boolean = false, +) + +@Serializable +data class MessageReactionToggleRequestDto( + val emoji: String, +) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/message/local/entity/MessageEntity.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/message/local/entity/MessageEntity.kt index 1115293..1b93594 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/message/local/entity/MessageEntity.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/message/local/entity/MessageEntity.kt @@ -29,12 +29,16 @@ data class MessageEntity( val senderAvatarUrl: String?, @ColumnInfo(name = "reply_to_message_id") val replyToMessageId: Long?, + @ColumnInfo(name = "forwarded_from_message_id") + val forwardedFromMessageId: Long?, @ColumnInfo(name = "type") val type: String, @ColumnInfo(name = "text") val text: String?, @ColumnInfo(name = "status") val status: String?, + @ColumnInfo(name = "attachment_waveform_json") + val attachmentWaveformJson: String?, @ColumnInfo(name = "created_at") val createdAt: String, @ColumnInfo(name = "updated_at") diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/message/mapper/MessageMappers.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/message/mapper/MessageMappers.kt index 1437c58..87350b2 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/message/mapper/MessageMappers.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/message/mapper/MessageMappers.kt @@ -1,9 +1,14 @@ package ru.daemonlord.messenger.data.message.mapper +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import ru.daemonlord.messenger.data.message.dto.MessageReadDto import ru.daemonlord.messenger.data.message.local.entity.MessageEntity +import ru.daemonlord.messenger.domain.message.model.MessageReaction import ru.daemonlord.messenger.domain.message.model.MessageItem +private val mapperJson = Json { ignoreUnknownKeys = true } + fun MessageReadDto.toEntity(): MessageEntity { return MessageEntity( id = id, @@ -13,9 +18,11 @@ fun MessageReadDto.toEntity(): MessageEntity { senderUsername = null, senderAvatarUrl = null, replyToMessageId = replyToMessageId, + forwardedFromMessageId = forwardedFromMessageId, type = type, text = text, status = deliveryStatus, + attachmentWaveformJson = attachmentWaveform?.let { mapperJson.encodeToString(it) }, createdAt = createdAt, updatedAt = updatedAt, ) @@ -34,5 +41,20 @@ fun MessageEntity.toDomain(currentUserId: Long?): MessageItem { isOutgoing = currentUserId != null && currentUserId == senderId, status = status, replyToMessageId = replyToMessageId, + forwardedFromMessageId = forwardedFromMessageId, + attachmentWaveform = attachmentWaveformJson.toWaveformOrNull(), ) } + +fun ru.daemonlord.messenger.data.message.dto.MessageReactionDto.toDomain(): MessageReaction { + return MessageReaction( + emoji = emoji, + count = count, + reacted = reacted, + ) +} + +private fun String?.toWaveformOrNull(): List? { + if (this.isNullOrBlank()) return null + return runCatching { mapperJson.decodeFromString>(this) }.getOrNull() +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt index 7014c1e..1fc7af2 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt @@ -1,10 +1,17 @@ package ru.daemonlord.messenger.data.message.repository import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.withContext +import kotlinx.coroutines.launch import retrofit2.HttpException import ru.daemonlord.messenger.data.chat.local.dao.ChatDao import ru.daemonlord.messenger.data.message.api.MessageApiService +import ru.daemonlord.messenger.core.token.TokenRepository import ru.daemonlord.messenger.data.message.local.dao.MessageDao import ru.daemonlord.messenger.data.message.local.entity.MessageEntity import ru.daemonlord.messenger.data.message.mapper.toDomain @@ -14,15 +21,22 @@ import ru.daemonlord.messenger.domain.common.AppError import ru.daemonlord.messenger.domain.common.AppResult import ru.daemonlord.messenger.domain.media.repository.MediaRepository import ru.daemonlord.messenger.domain.message.model.MessageItem +import ru.daemonlord.messenger.domain.message.model.MessageReaction import ru.daemonlord.messenger.domain.message.repository.MessageRepository import ru.daemonlord.messenger.data.message.dto.MessageCreateRequestDto +import ru.daemonlord.messenger.data.message.dto.MessageForwardRequestDto +import ru.daemonlord.messenger.data.message.dto.MessageReactionToggleRequestDto +import ru.daemonlord.messenger.data.message.dto.MessageStatusUpdateRequestDto import ru.daemonlord.messenger.data.message.dto.MessageUpdateRequestDto import java.io.IOException +import java.util.Base64 import java.util.UUID import javax.inject.Inject import javax.inject.Singleton -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive @Singleton class NetworkMessageRepository @Inject constructor( @@ -30,10 +44,21 @@ class NetworkMessageRepository @Inject constructor( private val messageDao: MessageDao, private val chatDao: ChatDao, private val mediaRepository: MediaRepository, + tokenRepository: TokenRepository, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, ) : MessageRepository { - private val currentUserId: Long? = null + @Volatile + private var currentUserId: Long? = null + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + init { + scope.launch { + tokenRepository.observeTokens().collectLatest { tokens -> + currentUserId = tokens?.accessToken?.extractUserIdFromJwt() + } + } + } override fun observeMessages(chatId: Long): Flow> { return messageDao.observeRecentMessages(chatId = chatId).map { entities -> @@ -85,9 +110,11 @@ class NetworkMessageRepository @Inject constructor( senderUsername = null, senderAvatarUrl = null, replyToMessageId = replyToMessageId, + forwardedFromMessageId = null, type = "text", text = text, status = "pending", + attachmentWaveformJson = null, createdAt = java.time.Instant.now().toString(), updatedAt = null, ) @@ -169,9 +196,11 @@ class NetworkMessageRepository @Inject constructor( senderUsername = null, senderAvatarUrl = null, replyToMessageId = replyToMessageId, + forwardedFromMessageId = null, type = messageType, text = caption, status = "pending", + attachmentWaveformJson = null, createdAt = java.time.Instant.now().toString(), updatedAt = null, ) @@ -224,6 +253,84 @@ class NetworkMessageRepository @Inject constructor( } } + override suspend fun markMessageDelivered(chatId: Long, messageId: Long): AppResult = withContext(ioDispatcher) { + try { + messageApiService.updateMessageStatus( + request = MessageStatusUpdateRequestDto( + chatId = chatId, + messageId = messageId, + status = "message_delivered", + ) + ) + AppResult.Success(Unit) + } catch (error: Throwable) { + AppResult.Error(error.toAppError()) + } + } + + override suspend fun markMessageRead(chatId: Long, messageId: Long): AppResult = withContext(ioDispatcher) { + try { + messageApiService.updateMessageStatus( + request = MessageStatusUpdateRequestDto( + chatId = chatId, + messageId = messageId, + status = "message_read", + ) + ) + AppResult.Success(Unit) + } catch (error: Throwable) { + AppResult.Error(error.toAppError()) + } + } + + override suspend fun forwardMessage( + messageId: Long, + targetChatId: Long, + includeAuthor: Boolean, + ): AppResult = withContext(ioDispatcher) { + try { + val forwarded = messageApiService.forwardMessage( + messageId = messageId, + request = MessageForwardRequestDto( + targetChatId = targetChatId, + includeAuthor = includeAuthor, + ), + ) + messageDao.upsertMessages(listOf(forwarded.toEntity())) + chatDao.updateLastMessage( + chatId = targetChatId, + lastMessageText = forwarded.text, + lastMessageType = forwarded.type, + lastMessageCreatedAt = forwarded.createdAt, + updatedSortAt = forwarded.createdAt, + ) + AppResult.Success(Unit) + } catch (error: Throwable) { + AppResult.Error(error.toAppError()) + } + } + + override suspend fun listReactions(messageId: Long): AppResult> = withContext(ioDispatcher) { + try { + val reactions = messageApiService.listReactions(messageId = messageId).map { it.toDomain() } + AppResult.Success(reactions) + } catch (error: Throwable) { + AppResult.Error(error.toAppError()) + } + } + + override suspend fun toggleReaction(messageId: Long, emoji: String): AppResult> = withContext(ioDispatcher) { + try { + val reactions = messageApiService.toggleReaction( + messageId = messageId, + request = MessageReactionToggleRequestDto(emoji = emoji), + ).map { it.toDomain() } + AppResult.Success(reactions) + } catch (error: Throwable) { + AppResult.Error(error.toAppError()) + } + } + private fun mapMimeToMessageType(mimeType: String): String { return when { mimeType.startsWith("image/") -> "image" @@ -244,4 +351,24 @@ class NetworkMessageRepository @Inject constructor( else -> AppError.Unknown(cause = this) } } + + private fun String.extractUserIdFromJwt(): Long? { + val payloadPart = split('.').getOrNull(1) ?: return null + val normalized = payloadPart + .replace('-', '+') + .replace('_', '/') + .let { part -> + when (part.length % 4) { + 2 -> "$part==" + 3 -> "$part=" + else -> part + } + } + val payloadJson = runCatching { + String(Base64.getDecoder().decode(normalized), Charsets.UTF_8) + }.getOrNull() ?: return null + return runCatching { + Json.parseToJsonElement(payloadJson).jsonObject["sub"]?.jsonPrimitive?.content?.toLongOrNull() + }.getOrNull() + } } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/message/model/MessageItem.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/model/MessageItem.kt index 4ed9ed7..d3fbb60 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/domain/message/model/MessageItem.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/model/MessageItem.kt @@ -12,4 +12,6 @@ data class MessageItem( val isOutgoing: Boolean, val status: String?, val replyToMessageId: Long?, + val forwardedFromMessageId: Long?, + val attachmentWaveform: List?, ) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/message/model/MessageReaction.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/model/MessageReaction.kt new file mode 100644 index 0000000..822ebe0 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/model/MessageReaction.kt @@ -0,0 +1,7 @@ +package ru.daemonlord.messenger.domain.message.model + +data class MessageReaction( + val emoji: String, + val count: Int, + val reacted: Boolean, +) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/message/repository/MessageRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/repository/MessageRepository.kt index 0e9ef0a..98348a7 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/domain/message/repository/MessageRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/repository/MessageRepository.kt @@ -3,6 +3,7 @@ package ru.daemonlord.messenger.domain.message.repository import kotlinx.coroutines.flow.Flow import ru.daemonlord.messenger.domain.common.AppResult import ru.daemonlord.messenger.domain.message.model.MessageItem +import ru.daemonlord.messenger.domain.message.model.MessageReaction interface MessageRepository { fun observeMessages(chatId: Long): Flow> @@ -19,4 +20,9 @@ interface MessageRepository { ): AppResult suspend fun editMessage(messageId: Long, newText: String): AppResult suspend fun deleteMessage(messageId: Long, forAll: Boolean): AppResult + suspend fun markMessageDelivered(chatId: Long, messageId: Long): AppResult + suspend fun markMessageRead(chatId: Long, messageId: Long): AppResult + suspend fun forwardMessage(messageId: Long, targetChatId: Long, includeAuthor: Boolean = true): AppResult + suspend fun listReactions(messageId: Long): AppResult> + suspend fun toggleReaction(messageId: Long, emoji: String): AppResult> } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/ForwardMessageUseCase.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/ForwardMessageUseCase.kt new file mode 100644 index 0000000..1b25c09 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/ForwardMessageUseCase.kt @@ -0,0 +1,17 @@ +package ru.daemonlord.messenger.domain.message.usecase + +import ru.daemonlord.messenger.domain.common.AppResult +import ru.daemonlord.messenger.domain.message.repository.MessageRepository +import javax.inject.Inject + +class ForwardMessageUseCase @Inject constructor( + private val repository: MessageRepository, +) { + suspend operator fun invoke(messageId: Long, targetChatId: Long, includeAuthor: Boolean = true): AppResult { + return repository.forwardMessage( + messageId = messageId, + targetChatId = targetChatId, + includeAuthor = includeAuthor, + ) + } +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/ListMessageReactionsUseCase.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/ListMessageReactionsUseCase.kt new file mode 100644 index 0000000..2d18646 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/ListMessageReactionsUseCase.kt @@ -0,0 +1,14 @@ +package ru.daemonlord.messenger.domain.message.usecase + +import ru.daemonlord.messenger.domain.common.AppResult +import ru.daemonlord.messenger.domain.message.model.MessageReaction +import ru.daemonlord.messenger.domain.message.repository.MessageRepository +import javax.inject.Inject + +class ListMessageReactionsUseCase @Inject constructor( + private val repository: MessageRepository, +) { + suspend operator fun invoke(messageId: Long): AppResult> { + return repository.listReactions(messageId = messageId) + } +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/MarkMessageDeliveredUseCase.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/MarkMessageDeliveredUseCase.kt new file mode 100644 index 0000000..fdfe145 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/MarkMessageDeliveredUseCase.kt @@ -0,0 +1,13 @@ +package ru.daemonlord.messenger.domain.message.usecase + +import ru.daemonlord.messenger.domain.common.AppResult +import ru.daemonlord.messenger.domain.message.repository.MessageRepository +import javax.inject.Inject + +class MarkMessageDeliveredUseCase @Inject constructor( + private val repository: MessageRepository, +) { + suspend operator fun invoke(chatId: Long, messageId: Long): AppResult { + return repository.markMessageDelivered(chatId = chatId, messageId = messageId) + } +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/MarkMessageReadUseCase.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/MarkMessageReadUseCase.kt new file mode 100644 index 0000000..d52526e --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/MarkMessageReadUseCase.kt @@ -0,0 +1,13 @@ +package ru.daemonlord.messenger.domain.message.usecase + +import ru.daemonlord.messenger.domain.common.AppResult +import ru.daemonlord.messenger.domain.message.repository.MessageRepository +import javax.inject.Inject + +class MarkMessageReadUseCase @Inject constructor( + private val repository: MessageRepository, +) { + suspend operator fun invoke(chatId: Long, messageId: Long): AppResult { + return repository.markMessageRead(chatId = chatId, messageId = messageId) + } +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/ToggleMessageReactionUseCase.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/ToggleMessageReactionUseCase.kt new file mode 100644 index 0000000..c0b1dc3 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/ToggleMessageReactionUseCase.kt @@ -0,0 +1,14 @@ +package ru.daemonlord.messenger.domain.message.usecase + +import ru.daemonlord.messenger.domain.common.AppResult +import ru.daemonlord.messenger.domain.message.model.MessageReaction +import ru.daemonlord.messenger.domain.message.repository.MessageRepository +import javax.inject.Inject + +class ToggleMessageReactionUseCase @Inject constructor( + private val repository: MessageRepository, +) { + suspend operator fun invoke(messageId: Long, emoji: String): AppResult> { + return repository.toggleReaction(messageId = messageId, emoji = emoji) + } +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/realtime/usecase/HandleRealtimeEventsUseCase.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/realtime/usecase/HandleRealtimeEventsUseCase.kt index 09db8aa..cf39262 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/domain/realtime/usecase/HandleRealtimeEventsUseCase.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/realtime/usecase/HandleRealtimeEventsUseCase.kt @@ -43,9 +43,11 @@ class HandleRealtimeEventsUseCase @Inject constructor( senderUsername = null, senderAvatarUrl = null, replyToMessageId = event.replyToMessageId, + forwardedFromMessageId = null, type = event.type ?: "text", text = event.text, status = null, + attachmentWaveformJson = null, createdAt = event.createdAt ?: java.time.Instant.now().toString(), updatedAt = null, ) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt index f2ab562..1b5297e 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt @@ -60,6 +60,10 @@ fun ChatRoute( onReplySelected = viewModel::onReplySelected, onEditSelected = viewModel::onEditSelected, onDeleteSelected = viewModel::onDeleteSelected, + onForwardSelected = viewModel::onForwardSelected, + onForwardDismiss = viewModel::onForwardDismiss, + onForwardTargetSelected = viewModel::onForwardTargetSelected, + onToggleReaction = viewModel::onToggleReaction, onCancelComposeAction = viewModel::onCancelComposeAction, onLoadMore = viewModel::loadMore, onPickMedia = { pickMediaLauncher.launch("*/*") }, @@ -76,6 +80,10 @@ fun ChatScreen( onReplySelected: (MessageItem) -> Unit, onEditSelected: (MessageItem) -> Unit, onDeleteSelected: (Boolean) -> Unit, + onForwardSelected: () -> Unit, + onForwardDismiss: () -> Unit, + onForwardTargetSelected: (Long) -> Unit, + onToggleReaction: (String) -> Unit, onCancelComposeAction: () -> Unit, onLoadMore: () -> Unit, onPickMedia: () -> Unit, @@ -120,6 +128,7 @@ fun ChatScreen( MessageBubble( message = message, isSelected = state.selectedMessage?.id == message.id, + reactions = state.reactionByMessageId[message.id].orEmpty(), onLongPress = { onSelectMessage(message) }, ) } @@ -135,12 +144,52 @@ fun ChatScreen( horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Button(onClick = { onReplySelected(state.selectedMessage) }) { Text("Reply") } - Button(onClick = { onEditSelected(state.selectedMessage) }) { Text("Edit") } + Button( + onClick = { onEditSelected(state.selectedMessage) }, + enabled = state.selectedCanEdit, + ) { Text("Edit") } Button(onClick = { onDeleteSelected(false) }) { Text("Delete") } + Button( + onClick = { onDeleteSelected(true) }, + enabled = state.selectedCanDeleteForAll, + ) { Text("Del for all") } + Button(onClick = onForwardSelected) { Text("Forward") } + Button(onClick = { onToggleReaction("\uD83D\uDC4D") }) { Text("\uD83D\uDC4D") } + Button(onClick = { onToggleReaction("\uD83D\uDE02") }) { Text("\uD83D\uDE02") } Button(onClick = { onSelectMessage(null) }) { Text("Close") } } } + if (state.forwardingMessage != null) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.secondaryContainer) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = "Forward message #${state.forwardingMessage.id}", + style = MaterialTheme.typography.labelLarge, + ) + if (state.availableForwardTargets.isEmpty()) { + Text("No available chats") + } else { + state.availableForwardTargets.forEach { target -> + Button( + onClick = { onForwardTargetSelected(target.chatId) }, + enabled = !state.isForwarding, + ) { + Text(target.title) + } + } + } + Button(onClick = onForwardDismiss, enabled = !state.isForwarding) { + Text(if (state.isForwarding) "Forwarding..." else "Cancel") + } + } + } + if (state.replyToMessage != null || state.editingMessage != null) { val header = if (state.editingMessage != null) { "Editing message #${state.editingMessage.id}" @@ -231,6 +280,7 @@ private fun Uri.readMediaPayload(context: Context): PickedMediaPayload? { private fun MessageBubble( message: MessageItem, isSelected: Boolean, + reactions: List, onLongPress: () -> Unit, ) { val isOutgoing = message.isOutgoing @@ -264,11 +314,33 @@ private fun MessageBubble( text = message.text ?: "[${message.type}]", style = MaterialTheme.typography.bodyMedium, ) + if (message.forwardedFromMessageId != null) { + Text( + text = "Forwarded from #${message.forwardedFromMessageId}", + style = MaterialTheme.typography.labelSmall, + ) + } + if (reactions.isNotEmpty()) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + reactions.forEach { reaction -> + Text( + text = "${reaction.emoji} ${reaction.count}", + style = MaterialTheme.typography.labelSmall, + ) + } + } + } Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, ) { - val status = if (message.status.isNullOrBlank()) "" else " ${message.status}" + val status = when (message.status) { + "read" -> " \u2713\u2713" + "delivered" -> " \u2713\u2713" + "sent" -> " \u2713" + "pending" -> " ..." + else -> "" + } Text( text = "${message.id}$status", style = MaterialTheme.typography.labelSmall, diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt index 7e8936c..cba1cc9 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt @@ -8,19 +8,28 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import ru.daemonlord.messenger.domain.chat.usecase.ObserveChatsUseCase import ru.daemonlord.messenger.domain.common.AppError import ru.daemonlord.messenger.domain.common.AppResult import ru.daemonlord.messenger.domain.message.model.MessageItem import ru.daemonlord.messenger.domain.message.usecase.DeleteMessageUseCase import ru.daemonlord.messenger.domain.message.usecase.EditMessageUseCase +import ru.daemonlord.messenger.domain.message.usecase.ForwardMessageUseCase +import ru.daemonlord.messenger.domain.message.usecase.ListMessageReactionsUseCase import ru.daemonlord.messenger.domain.message.usecase.LoadMoreMessagesUseCase +import ru.daemonlord.messenger.domain.message.usecase.MarkMessageDeliveredUseCase +import ru.daemonlord.messenger.domain.message.usecase.MarkMessageReadUseCase import ru.daemonlord.messenger.domain.message.usecase.ObserveMessagesUseCase import ru.daemonlord.messenger.domain.message.usecase.SendMediaMessageUseCase import ru.daemonlord.messenger.domain.message.usecase.SendTextMessageUseCase import ru.daemonlord.messenger.domain.message.usecase.SyncRecentMessagesUseCase +import ru.daemonlord.messenger.domain.message.usecase.ToggleMessageReactionUseCase import ru.daemonlord.messenger.domain.realtime.usecase.HandleRealtimeEventsUseCase +import java.time.Instant +import java.time.temporal.ChronoUnit import javax.inject.Inject @HiltViewModel @@ -33,12 +42,20 @@ class ChatViewModel @Inject constructor( private val sendMediaMessageUseCase: SendMediaMessageUseCase, private val editMessageUseCase: EditMessageUseCase, private val deleteMessageUseCase: DeleteMessageUseCase, + private val markMessageDeliveredUseCase: MarkMessageDeliveredUseCase, + private val markMessageReadUseCase: MarkMessageReadUseCase, + private val forwardMessageUseCase: ForwardMessageUseCase, + private val listMessageReactionsUseCase: ListMessageReactionsUseCase, + private val toggleMessageReactionUseCase: ToggleMessageReactionUseCase, + private val observeChatsUseCase: ObserveChatsUseCase, private val handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase, ) : ViewModel() { private val chatId: Long = checkNotNull(savedStateHandle["chatId"]) private val _uiState = MutableStateFlow(MessageUiState(chatId = chatId)) val uiState: StateFlow = _uiState.asStateFlow() + private var lastDeliveredMessageId: Long? = null + private var lastReadMessageId: Long? = null init { handleRealtimeEventsUseCase.start() @@ -51,7 +68,16 @@ class ChatViewModel @Inject constructor( } fun onSelectMessage(message: MessageItem?) { - _uiState.update { it.copy(selectedMessage = message) } + _uiState.update { + it.copy( + selectedMessage = message, + selectedCanEdit = message?.let(::canEdit) == true, + selectedCanDeleteForAll = message?.let(::canDeleteForAll) == true, + ) + } + if (message != null) { + loadReactions(messageId = message.id) + } } fun onReplySelected(message: MessageItem) { @@ -60,6 +86,8 @@ class ChatViewModel @Inject constructor( replyToMessage = message, editingMessage = null, selectedMessage = null, + selectedCanEdit = false, + selectedCanDeleteForAll = false, ) } } @@ -70,6 +98,8 @@ class ChatViewModel @Inject constructor( editingMessage = message, replyToMessage = null, selectedMessage = null, + selectedCanEdit = false, + selectedCanDeleteForAll = false, inputText = message.text.orEmpty(), ) } @@ -80,15 +110,103 @@ class ChatViewModel @Inject constructor( it.copy( replyToMessage = null, editingMessage = null, + selectedMessage = null, + selectedCanEdit = false, + selectedCanDeleteForAll = false, ) } } fun onDeleteSelected(forAll: Boolean = false) { val selected = uiState.value.selectedMessage ?: return + if (forAll && !canDeleteForAll(selected)) { + _uiState.update { it.copy(errorMessage = "Delete for all is available only for your own messages.") } + return + } viewModelScope.launch { when (val result = deleteMessageUseCase(selected.id, forAll = forAll)) { - is AppResult.Success -> _uiState.update { it.copy(selectedMessage = null) } + is AppResult.Success -> _uiState.update { + it.copy( + selectedMessage = null, + selectedCanEdit = false, + selectedCanDeleteForAll = false, + ) + } + is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) } + } + } + } + + fun onForwardSelected() { + val message = uiState.value.selectedMessage ?: return + viewModelScope.launch { + val targets = observeChatsUseCase(archived = false) + .first() + .asSequence() + .filter { it.id != chatId } + .take(20) + .map { ForwardTargetUiModel(chatId = it.id, title = it.displayTitle) } + .toList() + _uiState.update { + it.copy( + forwardingMessage = message, + availableForwardTargets = targets, + selectedMessage = null, + selectedCanEdit = false, + selectedCanDeleteForAll = false, + errorMessage = null, + ) + } + } + } + + fun onForwardDismiss() { + _uiState.update { + it.copy( + forwardingMessage = null, + availableForwardTargets = emptyList(), + isForwarding = false, + ) + } + } + + fun onForwardTargetSelected(targetChatId: Long) { + val message = uiState.value.forwardingMessage ?: return + viewModelScope.launch { + _uiState.update { it.copy(isForwarding = true, errorMessage = null) } + when (val result = forwardMessageUseCase(messageId = message.id, targetChatId = targetChatId)) { + is AppResult.Success -> { + _uiState.update { + it.copy( + isForwarding = false, + forwardingMessage = null, + availableForwardTargets = emptyList(), + ) + } + } + is AppResult.Error -> { + _uiState.update { + it.copy( + isForwarding = false, + errorMessage = result.reason.toUiMessage(), + ) + } + } + } + } + } + + fun onToggleReaction(emoji: String) { + val selected = uiState.value.selectedMessage ?: return + viewModelScope.launch { + when (val result = toggleMessageReactionUseCase(messageId = selected.id, emoji = emoji)) { + is AppResult.Success -> { + _uiState.update { + it.copy( + reactionByMessageId = it.reactionByMessageId + (selected.id to result.data), + ) + } + } is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) } } } @@ -101,6 +219,15 @@ class ChatViewModel @Inject constructor( viewModelScope.launch { _uiState.update { it.copy(isSending = true, errorMessage = null) } val editing = uiState.value.editingMessage + if (editing != null && !canEdit(editing)) { + _uiState.update { + it.copy( + isSending = false, + errorMessage = "This message can no longer be edited.", + ) + } + return@launch + } val result = if (editing != null) { editMessageUseCase(messageId = editing.id, newText = text) } else { @@ -198,6 +325,7 @@ class ChatViewModel @Inject constructor( messages = messages.sortedBy { msg -> msg.id }, ) } + acknowledgeLatestIncoming(messages) } } } @@ -217,6 +345,49 @@ class ChatViewModel @Inject constructor( } } + private fun loadReactions(messageId: Long) { + viewModelScope.launch { + when (val result = listMessageReactionsUseCase(messageId = messageId)) { + is AppResult.Success -> { + _uiState.update { + it.copy(reactionByMessageId = it.reactionByMessageId + (messageId to result.data)) + } + } + is AppResult.Error -> Unit + } + } + } + + private fun acknowledgeLatestIncoming(messages: List) { + val latestIncoming = messages + .asReversed() + .firstOrNull { !it.isOutgoing } + ?: return + + if (lastDeliveredMessageId != latestIncoming.id) { + lastDeliveredMessageId = latestIncoming.id + viewModelScope.launch { + markMessageDeliveredUseCase(chatId = chatId, messageId = latestIncoming.id) + } + } + if (lastReadMessageId != latestIncoming.id) { + lastReadMessageId = latestIncoming.id + viewModelScope.launch { + markMessageReadUseCase(chatId = chatId, messageId = latestIncoming.id) + } + } + } + + fun canEdit(message: MessageItem): Boolean { + if (!message.isOutgoing || message.type != "text") return false + val createdAt = runCatching { Instant.parse(message.createdAt) }.getOrNull() ?: return false + return createdAt.isAfter(Instant.now().minus(7, ChronoUnit.DAYS)) + } + + fun canDeleteForAll(message: MessageItem): Boolean { + return message.isOutgoing + } + private fun AppError.toUiMessage(): String { return when (this) { AppError.Network -> "Network error." diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageUiState.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageUiState.kt index 7cf5137..ec82dd1 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageUiState.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageUiState.kt @@ -1,6 +1,7 @@ package ru.daemonlord.messenger.ui.chat import ru.daemonlord.messenger.domain.message.model.MessageItem +import ru.daemonlord.messenger.domain.message.model.MessageReaction data class MessageUiState( val chatId: Long = 0L, @@ -14,4 +15,15 @@ data class MessageUiState( val replyToMessage: MessageItem? = null, val editingMessage: MessageItem? = null, val selectedMessage: MessageItem? = null, + val selectedCanEdit: Boolean = false, + val selectedCanDeleteForAll: Boolean = false, + val reactionByMessageId: Map> = emptyMap(), + val forwardingMessage: MessageItem? = null, + val availableForwardTargets: List = emptyList(), + val isForwarding: Boolean = false, +) + +data class ForwardTargetUiModel( + val chatId: Long, + val title: String, ) diff --git a/android/app/src/test/java/ru/daemonlord/messenger/data/message/local/dao/MessageDaoTest.kt b/android/app/src/test/java/ru/daemonlord/messenger/data/message/local/dao/MessageDaoTest.kt index 0ed3351..12d03d6 100644 --- a/android/app/src/test/java/ru/daemonlord/messenger/data/message/local/dao/MessageDaoTest.kt +++ b/android/app/src/test/java/ru/daemonlord/messenger/data/message/local/dao/MessageDaoTest.kt @@ -69,9 +69,11 @@ class MessageDaoTest { senderUsername = "user", senderAvatarUrl = null, replyToMessageId = null, + forwardedFromMessageId = null, type = "text", text = text, status = null, + attachmentWaveformJson = null, createdAt = "2026-03-08T12:00:00Z", updatedAt = null, ) diff --git a/docs/android-checklist.md b/docs/android-checklist.md index d60f8a8..c70409c 100644 --- a/docs/android-checklist.md +++ b/docs/android-checklist.md @@ -53,9 +53,9 @@ - [x] Reply/quote - [x] Edit (<=7 дней) - [x] Delete for me / for all (по правам) -- [ ] Forward в 1+ чатов -- [ ] Reactions -- [ ] Delivery/read states +- [x] Forward в 1+ чатов +- [x] Reactions +- [x] Delivery/read states ## 8. Медиа и вложения - [x] Upload image/video/file/audio