android: add channel role-based send permissions
Some checks failed
CI / test (push) Failing after 2m18s
Some checks failed
CI / test (push) Failing after 2m18s
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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>)
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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?,
|
||||
)
|
||||
|
||||
@@ -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?,
|
||||
)
|
||||
|
||||
@@ -25,6 +25,7 @@ fun ChatListLocalModel.toDomain(): ChatItem {
|
||||
lastMessageText = lastMessageText,
|
||||
lastMessageType = lastMessageType,
|
||||
lastMessageCreatedAt = lastMessageCreatedAt,
|
||||
myRole = myRole,
|
||||
updatedSortAt = updatedSortAt,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ fun ChatReadDto.toChatEntity(): ChatEntity {
|
||||
lastMessageText = lastMessageText,
|
||||
lastMessageType = lastMessageType,
|
||||
lastMessageCreatedAt = lastMessageCreatedAt,
|
||||
myRole = myRole,
|
||||
updatedSortAt = lastMessageCreatedAt ?: createdAt,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -21,5 +21,6 @@ data class ChatItem(
|
||||
val lastMessageText: String?,
|
||||
val lastMessageType: String?,
|
||||
val lastMessageCreatedAt: String?,
|
||||
val myRole: String?,
|
||||
val updatedSortAt: String?,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -88,6 +88,7 @@ class ChatDaoTest {
|
||||
lastMessageText = "hi",
|
||||
lastMessageType = "text",
|
||||
lastMessageCreatedAt = "2026-03-08T10:00:00Z",
|
||||
myRole = "member",
|
||||
updatedSortAt = "2026-03-08T10:00:00Z",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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. Поиск
|
||||
|
||||
Reference in New Issue
Block a user