android: add channel role-based send permissions
Some checks failed
CI / test (push) Failing after 2m18s

This commit is contained in:
Codex
2026-03-09 12:56:21 +03:00
parent 5760a0cb3f
commit 37396f4da5
18 changed files with 130 additions and 5 deletions

View File

@@ -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.

View File

@@ -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,
)

View File

@@ -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<List<ChatListLocalModel>>
@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<ChatListLocalModel?>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertChats(chats: List<ChatEntity>)

View File

@@ -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() {

View File

@@ -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?,
)

View File

@@ -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?,
)

View File

@@ -25,6 +25,7 @@ fun ChatListLocalModel.toDomain(): ChatItem {
lastMessageText = lastMessageText,
lastMessageType = lastMessageType,
lastMessageCreatedAt = lastMessageCreatedAt,
myRole = myRole,
updatedSortAt = updatedSortAt,
)
}

View File

@@ -27,6 +27,7 @@ fun ChatReadDto.toChatEntity(): ChatEntity {
lastMessageText = lastMessageText,
lastMessageType = lastMessageType,
lastMessageCreatedAt = lastMessageCreatedAt,
myRole = myRole,
updatedSortAt = lastMessageCreatedAt ?: createdAt,
)
}

View File

@@ -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<ChatItem?> {
return chatDao.observeChatById(chatId = chatId).map { it?.toDomain() }
}
override suspend fun refreshChats(archived: Boolean): AppResult<Unit> = withContext(ioDispatcher) {
try {
val chats = chatApiService.getChats(archived = archived)

View File

@@ -21,5 +21,6 @@ data class ChatItem(
val lastMessageText: String?,
val lastMessageType: String?,
val lastMessageCreatedAt: String?,
val myRole: String?,
val updatedSortAt: String?,
)

View File

@@ -6,6 +6,7 @@ import ru.daemonlord.messenger.domain.common.AppResult
interface ChatRepository {
fun observeChats(archived: Boolean): Flow<List<ChatItem>>
fun observeChat(chatId: Long): Flow<ChatItem?>
suspend fun refreshChats(archived: Boolean): AppResult<Unit>
suspend fun refreshChat(chatId: Long): AppResult<Unit>
suspend fun deleteChat(chatId: Long)

View File

@@ -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<ChatItem?> {
return repository.observeChat(chatId = chatId)
}
}

View File

@@ -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,

View File

@@ -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) }

View File

@@ -21,6 +21,8 @@ data class MessageUiState(
val forwardingMessage: MessageItem? = null,
val availableForwardTargets: List<ForwardTargetUiModel> = emptyList(),
val isForwarding: Boolean = false,
val canSendMessages: Boolean = true,
val sendRestrictionText: String? = null,
)
data class ForwardTargetUiModel(

View File

@@ -88,6 +88,7 @@ class ChatDaoTest {
lastMessageText = "hi",
lastMessageType = "text",
lastMessageCreatedAt = "2026-03-08T10:00:00Z",
myRole = "member",
updatedSortAt = "2026-03-08T10:00:00Z",
)
}

View File

@@ -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<ChatListLocalModel?> {
return chats.map { entities -> entities.firstOrNull { it.id == chatId }?.toLocalModel() }
}
override suspend fun upsertChats(chats: List<ChatEntity>) {
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,
)
}

View File

@@ -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. Поиск