diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index 65f5d41..dfd6792 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -124,3 +124,9 @@ - Synced and persisted message attachments during message refresh/pagination and after media send. - Extended message domain model with attachment list payload. - Added message attachment rendering in Chat UI: inline image preview, minimal image viewer overlay, and basic audio play/pause control. + +### Step 20 - Sprint P0 / 3) Roles/permissions baseline +- Extended chat data/domain models with `my_role` and added `observeChatById` stream in Room/repository. +- Added `ObserveChatUseCase` to expose per-chat permission state to message screen. +- Implemented channel send restrictions in `ChatViewModel`: sending/attach disabled for `member` role in `channel` chats. +- Added composer-level restriction hint in Chat UI to explain blocked actions. diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/dto/ChatDtos.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/dto/ChatDtos.kt index 9e0e02b..b5529d6 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/dto/ChatDtos.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/dto/ChatDtos.kt @@ -40,6 +40,8 @@ data class ChatReadDto( val lastMessageType: String? = null, @SerialName("last_message_created_at") val lastMessageCreatedAt: String? = null, + @SerialName("my_role") + val myRole: String? = null, @SerialName("created_at") val createdAt: String? = null, ) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/dao/ChatDao.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/dao/ChatDao.kt index e876832..d167c44 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/dao/ChatDao.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/dao/ChatDao.kt @@ -36,6 +36,7 @@ interface ChatDao { c.last_message_text, c.last_message_type, c.last_message_created_at, + c.my_role, c.updated_sort_at FROM chats c LEFT JOIN users_short u ON c.counterpart_user_id = u.id @@ -45,6 +46,39 @@ interface ChatDao { ) fun observeChats(archived: Boolean): Flow> + @Query( + """ + SELECT + c.id, + c.public_id, + c.type, + c.title, + COALESCE(c.display_title, u.display_name) AS display_title, + c.handle, + COALESCE(c.avatar_url, u.avatar_url) AS avatar_url, + c.archived, + c.pinned, + c.muted, + c.unread_count, + c.unread_mentions_count, + COALESCE(c.counterpart_name, u.display_name) AS counterpart_name, + COALESCE(c.counterpart_username, u.username) AS counterpart_username, + COALESCE(c.counterpart_avatar_url, u.avatar_url) AS counterpart_avatar_url, + c.counterpart_is_online, + c.counterpart_last_seen_at, + c.last_message_text, + c.last_message_type, + c.last_message_created_at, + c.my_role, + c.updated_sort_at + FROM chats c + LEFT JOIN users_short u ON c.counterpart_user_id = u.id + WHERE c.id = :chatId + LIMIT 1 + """ + ) + fun observeChatById(chatId: Long): Flow + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertChats(chats: List) 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 1088512..c93cd25 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 = 4, + version = 6, exportSchema = false, ) abstract class MessengerDatabase : RoomDatabase() { diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/entity/ChatEntity.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/entity/ChatEntity.kt index d197f78..72d94a4 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/entity/ChatEntity.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/entity/ChatEntity.kt @@ -56,6 +56,8 @@ data class ChatEntity( val lastMessageType: String?, @ColumnInfo(name = "last_message_created_at") val lastMessageCreatedAt: String?, + @ColumnInfo(name = "my_role") + val myRole: String?, @ColumnInfo(name = "updated_sort_at") val updatedSortAt: String?, ) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/model/ChatListLocalModel.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/model/ChatListLocalModel.kt index 5aa420a..2bc7c7c 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/model/ChatListLocalModel.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/model/ChatListLocalModel.kt @@ -43,6 +43,8 @@ data class ChatListLocalModel( val lastMessageType: String?, @ColumnInfo(name = "last_message_created_at") val lastMessageCreatedAt: String?, + @ColumnInfo(name = "my_role") + val myRole: String?, @ColumnInfo(name = "updated_sort_at") val updatedSortAt: String?, ) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/mapper/ChatLocalMapper.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/mapper/ChatLocalMapper.kt index 5368ad6..b7bd5fd 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/mapper/ChatLocalMapper.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/mapper/ChatLocalMapper.kt @@ -25,6 +25,7 @@ fun ChatListLocalModel.toDomain(): ChatItem { lastMessageText = lastMessageText, lastMessageType = lastMessageType, lastMessageCreatedAt = lastMessageCreatedAt, + myRole = myRole, updatedSortAt = updatedSortAt, ) } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/mapper/ChatRemoteMapper.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/mapper/ChatRemoteMapper.kt index 9319358..f4d9dbd 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/mapper/ChatRemoteMapper.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/mapper/ChatRemoteMapper.kt @@ -27,6 +27,7 @@ fun ChatReadDto.toChatEntity(): ChatEntity { lastMessageText = lastMessageText, lastMessageType = lastMessageType, lastMessageCreatedAt = lastMessageCreatedAt, + myRole = myRole, updatedSortAt = lastMessageCreatedAt ?: createdAt, ) } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/repository/NetworkChatRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/repository/NetworkChatRepository.kt index e3d24ca..9ffa972 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/repository/NetworkChatRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/repository/NetworkChatRepository.kt @@ -4,6 +4,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext import kotlinx.coroutines.launch import kotlinx.coroutines.channels.awaitClose @@ -43,6 +44,10 @@ class NetworkChatRepository @Inject constructor( } } + override fun observeChat(chatId: Long): Flow { + return chatDao.observeChatById(chatId = chatId).map { it?.toDomain() } + } + override suspend fun refreshChats(archived: Boolean): AppResult = withContext(ioDispatcher) { try { val chats = chatApiService.getChats(archived = archived) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/model/ChatItem.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/model/ChatItem.kt index 4b82bc2..f6c0e2e 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/model/ChatItem.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/model/ChatItem.kt @@ -21,5 +21,6 @@ data class ChatItem( val lastMessageText: String?, val lastMessageType: String?, val lastMessageCreatedAt: String?, + val myRole: String?, val updatedSortAt: String?, ) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/repository/ChatRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/repository/ChatRepository.kt index fc8e85e..83dcca9 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/repository/ChatRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/repository/ChatRepository.kt @@ -6,6 +6,7 @@ import ru.daemonlord.messenger.domain.common.AppResult interface ChatRepository { fun observeChats(archived: Boolean): Flow> + fun observeChat(chatId: Long): Flow suspend fun refreshChats(archived: Boolean): AppResult suspend fun refreshChat(chatId: Long): AppResult suspend fun deleteChat(chatId: Long) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/usecase/ObserveChatUseCase.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/usecase/ObserveChatUseCase.kt new file mode 100644 index 0000000..b47edbd --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/usecase/ObserveChatUseCase.kt @@ -0,0 +1,14 @@ +package ru.daemonlord.messenger.domain.chat.usecase + +import kotlinx.coroutines.flow.Flow +import ru.daemonlord.messenger.domain.chat.model.ChatItem +import ru.daemonlord.messenger.domain.chat.repository.ChatRepository +import javax.inject.Inject + +class ObserveChatUseCase @Inject constructor( + private val repository: ChatRepository, +) { + operator fun invoke(chatId: Long): Flow { + return repository.observeChat(chatId = chatId) + } +} 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 ba09bef..5ec3a96 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 @@ -233,7 +233,7 @@ fun ChatScreen( ) { Button( onClick = onPickMedia, - enabled = !state.isUploadingMedia, + enabled = state.canSendMessages && !state.isUploadingMedia, ) { Text(if (state.isUploadingMedia) "..." else "Attach") } @@ -246,12 +246,23 @@ fun ChatScreen( ) Button( onClick = onSendClick, - enabled = !state.isSending && !state.isUploadingMedia && state.inputText.isNotBlank(), + enabled = state.canSendMessages && + !state.isSending && + !state.isUploadingMedia && + state.inputText.isNotBlank(), ) { Text(if (state.isSending) "..." else "Send") } } + if (!state.canSendMessages && !state.sendRestrictionText.isNullOrBlank()) { + Text( + text = state.sendRestrictionText, + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp), + ) + } + if (!state.errorMessage.isNullOrBlank()) { Text( text = state.errorMessage, 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 cba1cc9..c5e3519 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 @@ -11,6 +11,7 @@ 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.ObserveChatUseCase import ru.daemonlord.messenger.domain.chat.usecase.ObserveChatsUseCase import ru.daemonlord.messenger.domain.common.AppError import ru.daemonlord.messenger.domain.common.AppResult @@ -47,6 +48,7 @@ class ChatViewModel @Inject constructor( private val forwardMessageUseCase: ForwardMessageUseCase, private val listMessageReactionsUseCase: ListMessageReactionsUseCase, private val toggleMessageReactionUseCase: ToggleMessageReactionUseCase, + private val observeChatUseCase: ObserveChatUseCase, private val observeChatsUseCase: ObserveChatsUseCase, private val handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase, ) : ViewModel() { @@ -59,6 +61,7 @@ class ChatViewModel @Inject constructor( init { handleRealtimeEventsUseCase.start() + observeChatPermissions() observeMessages() refresh() } @@ -231,6 +234,15 @@ class ChatViewModel @Inject constructor( val result = if (editing != null) { editMessageUseCase(messageId = editing.id, newText = text) } else { + if (!uiState.value.canSendMessages) { + _uiState.update { + it.copy( + isSending = false, + errorMessage = uiState.value.sendRestrictionText ?: "Sending is restricted in this chat.", + ) + } + return@launch + } sendTextMessageUseCase( chatId = chatId, text = text, @@ -330,6 +342,30 @@ class ChatViewModel @Inject constructor( } } + private fun observeChatPermissions() { + viewModelScope.launch { + observeChatUseCase(chatId).collectLatest { chat -> + if (chat == null) return@collectLatest + val role = chat.myRole?.lowercase() + val canSend = when (chat.type.lowercase()) { + "channel" -> role == "owner" || role == "admin" + else -> true + } + val restriction = if (canSend) { + null + } else { + "Only channel owner/admin can send messages." + } + _uiState.update { + it.copy( + canSendMessages = canSend, + sendRestrictionText = restriction, + ) + } + } + } + } + private fun refresh() { viewModelScope.launch { _uiState.update { it.copy(isLoading = true, errorMessage = null) } 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 ec82dd1..9b9055a 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 @@ -21,6 +21,8 @@ data class MessageUiState( val forwardingMessage: MessageItem? = null, val availableForwardTargets: List = emptyList(), val isForwarding: Boolean = false, + val canSendMessages: Boolean = true, + val sendRestrictionText: String? = null, ) data class ForwardTargetUiModel( diff --git a/android/app/src/test/java/ru/daemonlord/messenger/data/chat/local/dao/ChatDaoTest.kt b/android/app/src/test/java/ru/daemonlord/messenger/data/chat/local/dao/ChatDaoTest.kt index a18823c..5f56092 100644 --- a/android/app/src/test/java/ru/daemonlord/messenger/data/chat/local/dao/ChatDaoTest.kt +++ b/android/app/src/test/java/ru/daemonlord/messenger/data/chat/local/dao/ChatDaoTest.kt @@ -88,6 +88,7 @@ class ChatDaoTest { lastMessageText = "hi", lastMessageType = "text", lastMessageCreatedAt = "2026-03-08T10:00:00Z", + myRole = "member", updatedSortAt = "2026-03-08T10:00:00Z", ) } diff --git a/android/app/src/test/java/ru/daemonlord/messenger/data/chat/repository/NetworkChatRepositoryTest.kt b/android/app/src/test/java/ru/daemonlord/messenger/data/chat/repository/NetworkChatRepositoryTest.kt index d32a616..f23fbec 100644 --- a/android/app/src/test/java/ru/daemonlord/messenger/data/chat/repository/NetworkChatRepositoryTest.kt +++ b/android/app/src/test/java/ru/daemonlord/messenger/data/chat/repository/NetworkChatRepositoryTest.kt @@ -49,6 +49,7 @@ class NetworkChatRepositoryTest { lastMessageText = "Cached message", lastMessageType = "text", lastMessageCreatedAt = "2026-03-08T10:00:00Z", + myRole = "member", updatedSortAt = "2026-03-08T10:00:00Z", ) @@ -121,6 +122,10 @@ class NetworkChatRepositoryTest { } } + override fun observeChatById(chatId: Long): Flow { + return chats.map { entities -> entities.firstOrNull { it.id == chatId }?.toLocalModel() } + } + override suspend fun upsertChats(chats: List) { val merged = this.chats.value.associateBy { it.id }.toMutableMap() chats.forEach { merged[it.id] = it } @@ -180,6 +185,7 @@ class NetworkChatRepositoryTest { lastMessageText = lastMessageText, lastMessageType = lastMessageType, lastMessageCreatedAt = lastMessageCreatedAt, + myRole = myRole, updatedSortAt = updatedSortAt, ) } diff --git a/docs/android-checklist.md b/docs/android-checklist.md index b2f575e..9382418 100644 --- a/docs/android-checklist.md +++ b/docs/android-checklist.md @@ -77,9 +77,9 @@ - [ ] Create group/channel - [ ] Join/leave - [ ] Invite link (create/regenerate/join) -- [ ] Roles owner/admin/member +- [x] Roles owner/admin/member - [ ] Admin actions: add/remove/ban/unban/promote/demote -- [ ] Ограничения канала: писать только owner/admin +- [x] Ограничения канала: писать только owner/admin - [ ] Member visibility rules (скрытие списков/действий) ## 11. Поиск