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.
|
- Synced and persisted message attachments during message refresh/pagination and after media send.
|
||||||
- Extended message domain model with attachment list payload.
|
- 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.
|
- 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,
|
val lastMessageType: String? = null,
|
||||||
@SerialName("last_message_created_at")
|
@SerialName("last_message_created_at")
|
||||||
val lastMessageCreatedAt: String? = null,
|
val lastMessageCreatedAt: String? = null,
|
||||||
|
@SerialName("my_role")
|
||||||
|
val myRole: String? = null,
|
||||||
@SerialName("created_at")
|
@SerialName("created_at")
|
||||||
val createdAt: String? = null,
|
val createdAt: String? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ interface ChatDao {
|
|||||||
c.last_message_text,
|
c.last_message_text,
|
||||||
c.last_message_type,
|
c.last_message_type,
|
||||||
c.last_message_created_at,
|
c.last_message_created_at,
|
||||||
|
c.my_role,
|
||||||
c.updated_sort_at
|
c.updated_sort_at
|
||||||
FROM chats c
|
FROM chats c
|
||||||
LEFT JOIN users_short u ON c.counterpart_user_id = u.id
|
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>>
|
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)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun upsertChats(chats: List<ChatEntity>)
|
suspend fun upsertChats(chats: List<ChatEntity>)
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import ru.daemonlord.messenger.data.message.local.entity.MessageEntity
|
|||||||
MessageEntity::class,
|
MessageEntity::class,
|
||||||
MessageAttachmentEntity::class,
|
MessageAttachmentEntity::class,
|
||||||
],
|
],
|
||||||
version = 4,
|
version = 6,
|
||||||
exportSchema = false,
|
exportSchema = false,
|
||||||
)
|
)
|
||||||
abstract class MessengerDatabase : RoomDatabase() {
|
abstract class MessengerDatabase : RoomDatabase() {
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ data class ChatEntity(
|
|||||||
val lastMessageType: String?,
|
val lastMessageType: String?,
|
||||||
@ColumnInfo(name = "last_message_created_at")
|
@ColumnInfo(name = "last_message_created_at")
|
||||||
val lastMessageCreatedAt: String?,
|
val lastMessageCreatedAt: String?,
|
||||||
|
@ColumnInfo(name = "my_role")
|
||||||
|
val myRole: String?,
|
||||||
@ColumnInfo(name = "updated_sort_at")
|
@ColumnInfo(name = "updated_sort_at")
|
||||||
val updatedSortAt: String?,
|
val updatedSortAt: String?,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ data class ChatListLocalModel(
|
|||||||
val lastMessageType: String?,
|
val lastMessageType: String?,
|
||||||
@ColumnInfo(name = "last_message_created_at")
|
@ColumnInfo(name = "last_message_created_at")
|
||||||
val lastMessageCreatedAt: String?,
|
val lastMessageCreatedAt: String?,
|
||||||
|
@ColumnInfo(name = "my_role")
|
||||||
|
val myRole: String?,
|
||||||
@ColumnInfo(name = "updated_sort_at")
|
@ColumnInfo(name = "updated_sort_at")
|
||||||
val updatedSortAt: String?,
|
val updatedSortAt: String?,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ fun ChatListLocalModel.toDomain(): ChatItem {
|
|||||||
lastMessageText = lastMessageText,
|
lastMessageText = lastMessageText,
|
||||||
lastMessageType = lastMessageType,
|
lastMessageType = lastMessageType,
|
||||||
lastMessageCreatedAt = lastMessageCreatedAt,
|
lastMessageCreatedAt = lastMessageCreatedAt,
|
||||||
|
myRole = myRole,
|
||||||
updatedSortAt = updatedSortAt,
|
updatedSortAt = updatedSortAt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ fun ChatReadDto.toChatEntity(): ChatEntity {
|
|||||||
lastMessageText = lastMessageText,
|
lastMessageText = lastMessageText,
|
||||||
lastMessageType = lastMessageType,
|
lastMessageType = lastMessageType,
|
||||||
lastMessageCreatedAt = lastMessageCreatedAt,
|
lastMessageCreatedAt = lastMessageCreatedAt,
|
||||||
|
myRole = myRole,
|
||||||
updatedSortAt = lastMessageCreatedAt ?: createdAt,
|
updatedSortAt = lastMessageCreatedAt ?: createdAt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import kotlinx.coroutines.CoroutineDispatcher
|
|||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.collect
|
import kotlinx.coroutines.flow.collect
|
||||||
import kotlinx.coroutines.flow.channelFlow
|
import kotlinx.coroutines.flow.channelFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.channels.awaitClose
|
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) {
|
override suspend fun refreshChats(archived: Boolean): AppResult<Unit> = withContext(ioDispatcher) {
|
||||||
try {
|
try {
|
||||||
val chats = chatApiService.getChats(archived = archived)
|
val chats = chatApiService.getChats(archived = archived)
|
||||||
|
|||||||
@@ -21,5 +21,6 @@ data class ChatItem(
|
|||||||
val lastMessageText: String?,
|
val lastMessageText: String?,
|
||||||
val lastMessageType: String?,
|
val lastMessageType: String?,
|
||||||
val lastMessageCreatedAt: String?,
|
val lastMessageCreatedAt: String?,
|
||||||
|
val myRole: String?,
|
||||||
val updatedSortAt: String?,
|
val updatedSortAt: String?,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import ru.daemonlord.messenger.domain.common.AppResult
|
|||||||
|
|
||||||
interface ChatRepository {
|
interface ChatRepository {
|
||||||
fun observeChats(archived: Boolean): Flow<List<ChatItem>>
|
fun observeChats(archived: Boolean): Flow<List<ChatItem>>
|
||||||
|
fun observeChat(chatId: Long): Flow<ChatItem?>
|
||||||
suspend fun refreshChats(archived: Boolean): AppResult<Unit>
|
suspend fun refreshChats(archived: Boolean): AppResult<Unit>
|
||||||
suspend fun refreshChat(chatId: Long): AppResult<Unit>
|
suspend fun refreshChat(chatId: Long): AppResult<Unit>
|
||||||
suspend fun deleteChat(chatId: Long)
|
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(
|
Button(
|
||||||
onClick = onPickMedia,
|
onClick = onPickMedia,
|
||||||
enabled = !state.isUploadingMedia,
|
enabled = state.canSendMessages && !state.isUploadingMedia,
|
||||||
) {
|
) {
|
||||||
Text(if (state.isUploadingMedia) "..." else "Attach")
|
Text(if (state.isUploadingMedia) "..." else "Attach")
|
||||||
}
|
}
|
||||||
@@ -246,12 +246,23 @@ fun ChatScreen(
|
|||||||
)
|
)
|
||||||
Button(
|
Button(
|
||||||
onClick = onSendClick,
|
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")
|
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()) {
|
if (!state.errorMessage.isNullOrBlank()) {
|
||||||
Text(
|
Text(
|
||||||
text = state.errorMessage,
|
text = state.errorMessage,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.collectLatest
|
|||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
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.chat.usecase.ObserveChatsUseCase
|
||||||
import ru.daemonlord.messenger.domain.common.AppError
|
import ru.daemonlord.messenger.domain.common.AppError
|
||||||
import ru.daemonlord.messenger.domain.common.AppResult
|
import ru.daemonlord.messenger.domain.common.AppResult
|
||||||
@@ -47,6 +48,7 @@ class ChatViewModel @Inject constructor(
|
|||||||
private val forwardMessageUseCase: ForwardMessageUseCase,
|
private val forwardMessageUseCase: ForwardMessageUseCase,
|
||||||
private val listMessageReactionsUseCase: ListMessageReactionsUseCase,
|
private val listMessageReactionsUseCase: ListMessageReactionsUseCase,
|
||||||
private val toggleMessageReactionUseCase: ToggleMessageReactionUseCase,
|
private val toggleMessageReactionUseCase: ToggleMessageReactionUseCase,
|
||||||
|
private val observeChatUseCase: ObserveChatUseCase,
|
||||||
private val observeChatsUseCase: ObserveChatsUseCase,
|
private val observeChatsUseCase: ObserveChatsUseCase,
|
||||||
private val handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase,
|
private val handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
@@ -59,6 +61,7 @@ class ChatViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
handleRealtimeEventsUseCase.start()
|
handleRealtimeEventsUseCase.start()
|
||||||
|
observeChatPermissions()
|
||||||
observeMessages()
|
observeMessages()
|
||||||
refresh()
|
refresh()
|
||||||
}
|
}
|
||||||
@@ -231,6 +234,15 @@ class ChatViewModel @Inject constructor(
|
|||||||
val result = if (editing != null) {
|
val result = if (editing != null) {
|
||||||
editMessageUseCase(messageId = editing.id, newText = text)
|
editMessageUseCase(messageId = editing.id, newText = text)
|
||||||
} else {
|
} else {
|
||||||
|
if (!uiState.value.canSendMessages) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isSending = false,
|
||||||
|
errorMessage = uiState.value.sendRestrictionText ?: "Sending is restricted in this chat.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
sendTextMessageUseCase(
|
sendTextMessageUseCase(
|
||||||
chatId = chatId,
|
chatId = chatId,
|
||||||
text = text,
|
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() {
|
private fun refresh() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ data class MessageUiState(
|
|||||||
val forwardingMessage: MessageItem? = null,
|
val forwardingMessage: MessageItem? = null,
|
||||||
val availableForwardTargets: List<ForwardTargetUiModel> = emptyList(),
|
val availableForwardTargets: List<ForwardTargetUiModel> = emptyList(),
|
||||||
val isForwarding: Boolean = false,
|
val isForwarding: Boolean = false,
|
||||||
|
val canSendMessages: Boolean = true,
|
||||||
|
val sendRestrictionText: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class ForwardTargetUiModel(
|
data class ForwardTargetUiModel(
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ class ChatDaoTest {
|
|||||||
lastMessageText = "hi",
|
lastMessageText = "hi",
|
||||||
lastMessageType = "text",
|
lastMessageType = "text",
|
||||||
lastMessageCreatedAt = "2026-03-08T10:00:00Z",
|
lastMessageCreatedAt = "2026-03-08T10:00:00Z",
|
||||||
|
myRole = "member",
|
||||||
updatedSortAt = "2026-03-08T10:00:00Z",
|
updatedSortAt = "2026-03-08T10:00:00Z",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ class NetworkChatRepositoryTest {
|
|||||||
lastMessageText = "Cached message",
|
lastMessageText = "Cached message",
|
||||||
lastMessageType = "text",
|
lastMessageType = "text",
|
||||||
lastMessageCreatedAt = "2026-03-08T10:00:00Z",
|
lastMessageCreatedAt = "2026-03-08T10:00:00Z",
|
||||||
|
myRole = "member",
|
||||||
updatedSortAt = "2026-03-08T10:00:00Z",
|
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>) {
|
override suspend fun upsertChats(chats: List<ChatEntity>) {
|
||||||
val merged = this.chats.value.associateBy { it.id }.toMutableMap()
|
val merged = this.chats.value.associateBy { it.id }.toMutableMap()
|
||||||
chats.forEach { merged[it.id] = it }
|
chats.forEach { merged[it.id] = it }
|
||||||
@@ -180,6 +185,7 @@ class NetworkChatRepositoryTest {
|
|||||||
lastMessageText = lastMessageText,
|
lastMessageText = lastMessageText,
|
||||||
lastMessageType = lastMessageType,
|
lastMessageType = lastMessageType,
|
||||||
lastMessageCreatedAt = lastMessageCreatedAt,
|
lastMessageCreatedAt = lastMessageCreatedAt,
|
||||||
|
myRole = myRole,
|
||||||
updatedSortAt = updatedSortAt,
|
updatedSortAt = updatedSortAt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,9 +77,9 @@
|
|||||||
- [ ] Create group/channel
|
- [ ] Create group/channel
|
||||||
- [ ] Join/leave
|
- [ ] Join/leave
|
||||||
- [ ] Invite link (create/regenerate/join)
|
- [ ] Invite link (create/regenerate/join)
|
||||||
- [ ] Roles owner/admin/member
|
- [x] Roles owner/admin/member
|
||||||
- [ ] Admin actions: add/remove/ban/unban/promote/demote
|
- [ ] Admin actions: add/remove/ban/unban/promote/demote
|
||||||
- [ ] Ограничения канала: писать только owner/admin
|
- [x] Ограничения канала: писать только owner/admin
|
||||||
- [ ] Member visibility rules (скрытие списков/действий)
|
- [ ] Member visibility rules (скрытие списков/действий)
|
||||||
|
|
||||||
## 11. Поиск
|
## 11. Поиск
|
||||||
|
|||||||
Reference in New Issue
Block a user