android: add group channel invite and admin management baseline
Some checks failed
Android CI / android (push) Failing after 3m54s
Android Release / release (push) Has started running
CI / test (push) Has been cancelled

This commit is contained in:
Codex
2026-03-09 16:21:43 +03:00
parent 47190e354d
commit 862b18e305
12 changed files with 809 additions and 7 deletions

View File

@@ -404,3 +404,16 @@
- Added process-wide audio focus coordinator to enforce single active audio source: - Added process-wide audio focus coordinator to enforce single active audio source:
- attachment player pauses when another source starts, - attachment player pauses when another source starts,
- recording requests focus and stops competing playback. - recording requests focus and stops competing playback.
### Step 67 - Group/channel management baseline in Chat List
- Extended chat API/repository layer with management operations:
- create group/channel,
- discover + join/leave chats,
- invite link create/regenerate,
- members/bans listing and admin actions (add/remove/ban/unban/promote/demote).
- Added domain models for discover/member/ban items and repository mappings.
- Added in-app management panel in `ChatListScreen` (FAB toggle) for:
- creating group/channel,
- joining discovered chats,
- loading chat members/bans by chat id,
- executing admin/member visibility actions from one place.

View File

@@ -1,13 +1,21 @@
package ru.daemonlord.messenger.data.chat.api package ru.daemonlord.messenger.data.chat.api
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.PATCH
import retrofit2.http.Path import retrofit2.http.Path
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.Query import retrofit2.http.Query
import ru.daemonlord.messenger.data.chat.dto.ChatBanDto
import ru.daemonlord.messenger.data.chat.dto.ChatCreateRequestDto
import ru.daemonlord.messenger.data.chat.dto.ChatInviteLinkDto import ru.daemonlord.messenger.data.chat.dto.ChatInviteLinkDto
import ru.daemonlord.messenger.data.chat.dto.ChatJoinByInviteRequestDto import ru.daemonlord.messenger.data.chat.dto.ChatJoinByInviteRequestDto
import ru.daemonlord.messenger.data.chat.dto.ChatMemberAddRequestDto
import ru.daemonlord.messenger.data.chat.dto.ChatMemberDto
import ru.daemonlord.messenger.data.chat.dto.ChatMemberRoleUpdateRequestDto
import ru.daemonlord.messenger.data.chat.dto.ChatReadDto import ru.daemonlord.messenger.data.chat.dto.ChatReadDto
import ru.daemonlord.messenger.data.chat.dto.DiscoverChatDto
interface ChatApiService { interface ChatApiService {
@GET("/api/v1/chats") @GET("/api/v1/chats")
@@ -29,4 +37,65 @@ interface ChatApiService {
suspend fun joinByInvite( suspend fun joinByInvite(
@Body request: ChatJoinByInviteRequestDto, @Body request: ChatJoinByInviteRequestDto,
): ChatReadDto ): ChatReadDto
@POST("/api/v1/chats")
suspend fun createChat(
@Body request: ChatCreateRequestDto,
): ChatReadDto
@GET("/api/v1/chats/discover")
suspend fun discoverChats(
@Query("query") query: String? = null,
): List<DiscoverChatDto>
@POST("/api/v1/chats/{chat_id}/join")
suspend fun joinChat(
@Path("chat_id") chatId: Long,
): ChatReadDto
@POST("/api/v1/chats/{chat_id}/leave")
suspend fun leaveChat(
@Path("chat_id") chatId: Long,
)
@GET("/api/v1/chats/{chat_id}/members")
suspend fun listMembers(
@Path("chat_id") chatId: Long,
): List<ChatMemberDto>
@GET("/api/v1/chats/{chat_id}/bans")
suspend fun listBans(
@Path("chat_id") chatId: Long,
): List<ChatBanDto>
@POST("/api/v1/chats/{chat_id}/members")
suspend fun addMember(
@Path("chat_id") chatId: Long,
@Body request: ChatMemberAddRequestDto,
): ChatMemberDto
@PATCH("/api/v1/chats/{chat_id}/members/{user_id}/role")
suspend fun updateMemberRole(
@Path("chat_id") chatId: Long,
@Path("user_id") userId: Long,
@Body request: ChatMemberRoleUpdateRequestDto,
): ChatMemberDto
@DELETE("/api/v1/chats/{chat_id}/members/{user_id}")
suspend fun removeMember(
@Path("chat_id") chatId: Long,
@Path("user_id") userId: Long,
)
@POST("/api/v1/chats/{chat_id}/bans/{user_id}")
suspend fun banMember(
@Path("chat_id") chatId: Long,
@Path("user_id") userId: Long,
)
@DELETE("/api/v1/chats/{chat_id}/bans/{user_id}")
suspend fun unbanMember(
@Path("chat_id") chatId: Long,
@Path("user_id") userId: Long,
)
} }

View File

@@ -61,3 +61,60 @@ data class ChatInviteLinkDto(
data class ChatJoinByInviteRequestDto( data class ChatJoinByInviteRequestDto(
val token: String, val token: String,
) )
@Serializable
data class ChatCreateRequestDto(
val type: String,
val title: String? = null,
@SerialName("is_public")
val isPublic: Boolean = false,
val handle: String? = null,
val description: String? = null,
@SerialName("member_ids")
val memberIds: List<Long> = emptyList(),
)
@Serializable
data class DiscoverChatDto(
val id: Long,
val type: String,
@SerialName("display_title")
val displayTitle: String,
val handle: String? = null,
@SerialName("avatar_url")
val avatarUrl: String? = null,
@SerialName("is_member")
val isMember: Boolean = false,
)
@Serializable
data class ChatMemberDto(
@SerialName("user_id")
val userId: Long,
val role: String,
val name: String? = null,
val username: String? = null,
@SerialName("avatar_url")
val avatarUrl: String? = null,
)
@Serializable
data class ChatBanDto(
@SerialName("user_id")
val userId: Long,
@SerialName("banned_at")
val bannedAt: String? = null,
val name: String? = null,
val username: String? = null,
)
@Serializable
data class ChatMemberRoleUpdateRequestDto(
val role: String,
)
@Serializable
data class ChatMemberAddRequestDto(
@SerialName("user_id")
val userId: Long,
)

View File

@@ -9,7 +9,13 @@ import kotlinx.coroutines.withContext
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import ru.daemonlord.messenger.data.chat.api.ChatApiService import ru.daemonlord.messenger.data.chat.api.ChatApiService
import ru.daemonlord.messenger.data.chat.dto.ChatBanDto
import ru.daemonlord.messenger.data.chat.dto.ChatCreateRequestDto
import ru.daemonlord.messenger.data.chat.dto.ChatJoinByInviteRequestDto import ru.daemonlord.messenger.data.chat.dto.ChatJoinByInviteRequestDto
import ru.daemonlord.messenger.data.chat.dto.ChatMemberAddRequestDto
import ru.daemonlord.messenger.data.chat.dto.ChatMemberDto
import ru.daemonlord.messenger.data.chat.dto.ChatMemberRoleUpdateRequestDto
import ru.daemonlord.messenger.data.chat.dto.DiscoverChatDto
import ru.daemonlord.messenger.data.chat.local.dao.ChatDao import ru.daemonlord.messenger.data.chat.local.dao.ChatDao
import ru.daemonlord.messenger.data.chat.mapper.toChatEntity import ru.daemonlord.messenger.data.chat.mapper.toChatEntity
import ru.daemonlord.messenger.data.chat.mapper.toDomain import ru.daemonlord.messenger.data.chat.mapper.toDomain
@@ -17,7 +23,10 @@ import ru.daemonlord.messenger.data.chat.mapper.toUserShortEntityOrNull
import ru.daemonlord.messenger.data.common.toAppError import ru.daemonlord.messenger.data.common.toAppError
import ru.daemonlord.messenger.domain.chat.model.ChatInviteLink import ru.daemonlord.messenger.domain.chat.model.ChatInviteLink
import ru.daemonlord.messenger.di.IoDispatcher import ru.daemonlord.messenger.di.IoDispatcher
import ru.daemonlord.messenger.domain.chat.model.ChatBanItem
import ru.daemonlord.messenger.domain.chat.model.ChatItem import ru.daemonlord.messenger.domain.chat.model.ChatItem
import ru.daemonlord.messenger.domain.chat.model.ChatMemberItem
import ru.daemonlord.messenger.domain.chat.model.DiscoverChatItem
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
import ru.daemonlord.messenger.domain.common.AppResult import ru.daemonlord.messenger.domain.common.AppResult
import javax.inject.Inject import javax.inject.Inject
@@ -95,9 +104,169 @@ class NetworkChatRepository @Inject constructor(
} }
} }
override suspend fun createChat(
type: String,
title: String?,
isPublic: Boolean,
handle: String?,
description: String?,
memberIds: List<Long>,
): AppResult<ChatItem> = withContext(ioDispatcher) {
try {
val created = chatApiService.createChat(
request = ChatCreateRequestDto(
type = type,
title = title,
isPublic = isPublic,
handle = handle,
description = description,
memberIds = memberIds,
)
)
chatDao.upsertUsers(created.toUserShortEntityOrNull()?.let(::listOf).orEmpty())
val chatEntity = created.toChatEntity()
chatDao.upsertChats(listOf(chatEntity))
AppResult.Success(chatEntity.toDomain())
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun discoverChats(query: String?): AppResult<List<DiscoverChatItem>> = withContext(ioDispatcher) {
try {
AppResult.Success(chatApiService.discoverChats(query = query).map { it.toDomain() })
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun joinChat(chatId: Long): AppResult<ChatItem> = withContext(ioDispatcher) {
try {
val joined = chatApiService.joinChat(chatId = chatId)
chatDao.upsertUsers(joined.toUserShortEntityOrNull()?.let(::listOf).orEmpty())
val entity = joined.toChatEntity()
chatDao.upsertChats(listOf(entity))
AppResult.Success(entity.toDomain())
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun leaveChat(chatId: Long): AppResult<Unit> = withContext(ioDispatcher) {
try {
chatApiService.leaveChat(chatId = chatId)
chatDao.deleteChat(chatId = chatId)
AppResult.Success(Unit)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun listMembers(chatId: Long): AppResult<List<ChatMemberItem>> = withContext(ioDispatcher) {
try {
AppResult.Success(chatApiService.listMembers(chatId = chatId).map { it.toDomain() })
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun listBans(chatId: Long): AppResult<List<ChatBanItem>> = withContext(ioDispatcher) {
try {
AppResult.Success(chatApiService.listBans(chatId = chatId).map { it.toDomain() })
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun addMember(chatId: Long, userId: Long): AppResult<ChatMemberItem> = withContext(ioDispatcher) {
try {
AppResult.Success(
chatApiService.addMember(
chatId = chatId,
request = ChatMemberAddRequestDto(userId = userId),
).toDomain()
)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun updateMemberRole(chatId: Long, userId: Long, role: String): AppResult<ChatMemberItem> = withContext(ioDispatcher) {
try {
AppResult.Success(
chatApiService.updateMemberRole(
chatId = chatId,
userId = userId,
request = ChatMemberRoleUpdateRequestDto(role = role),
).toDomain()
)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun removeMember(chatId: Long, userId: Long): AppResult<Unit> = withContext(ioDispatcher) {
try {
chatApiService.removeMember(chatId = chatId, userId = userId)
AppResult.Success(Unit)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun banMember(chatId: Long, userId: Long): AppResult<Unit> = withContext(ioDispatcher) {
try {
chatApiService.banMember(chatId = chatId, userId = userId)
AppResult.Success(Unit)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun unbanMember(chatId: Long, userId: Long): AppResult<Unit> = withContext(ioDispatcher) {
try {
chatApiService.unbanMember(chatId = chatId, userId = userId)
AppResult.Success(Unit)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun deleteChat(chatId: Long) { override suspend fun deleteChat(chatId: Long) {
withContext(ioDispatcher) { withContext(ioDispatcher) {
chatDao.deleteChat(chatId = chatId) chatDao.deleteChat(chatId = chatId)
} }
} }
private fun DiscoverChatDto.toDomain(): DiscoverChatItem {
return DiscoverChatItem(
id = id,
type = type,
displayTitle = displayTitle,
handle = handle,
avatarUrl = avatarUrl,
isMember = isMember,
)
}
private fun ChatMemberDto.toDomain(): ChatMemberItem {
val fallbackName = username?.takeIf { it.isNotBlank() }?.let { "@$it" } ?: "User #$userId"
return ChatMemberItem(
userId = userId,
role = role,
name = name?.takeIf { it.isNotBlank() } ?: fallbackName,
username = username,
avatarUrl = avatarUrl,
)
}
private fun ChatBanDto.toDomain(): ChatBanItem {
val fallbackName = username?.takeIf { it.isNotBlank() }?.let { "@$it" } ?: "User #$userId"
return ChatBanItem(
userId = userId,
name = name?.takeIf { it.isNotBlank() } ?: fallbackName,
username = username,
bannedAt = bannedAt,
)
}
} }

View File

@@ -0,0 +1,8 @@
package ru.daemonlord.messenger.domain.chat.model
data class ChatBanItem(
val userId: Long,
val name: String,
val username: String?,
val bannedAt: String?,
)

View File

@@ -0,0 +1,9 @@
package ru.daemonlord.messenger.domain.chat.model
data class ChatMemberItem(
val userId: Long,
val role: String,
val name: String,
val username: String?,
val avatarUrl: String?,
)

View File

@@ -0,0 +1,10 @@
package ru.daemonlord.messenger.domain.chat.model
data class DiscoverChatItem(
val id: Long,
val type: String,
val displayTitle: String,
val handle: String?,
val avatarUrl: String?,
val isMember: Boolean,
)

View File

@@ -1,8 +1,11 @@
package ru.daemonlord.messenger.domain.chat.repository package ru.daemonlord.messenger.domain.chat.repository
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import ru.daemonlord.messenger.domain.chat.model.ChatBanItem
import ru.daemonlord.messenger.domain.chat.model.ChatInviteLink import ru.daemonlord.messenger.domain.chat.model.ChatInviteLink
import ru.daemonlord.messenger.domain.chat.model.ChatItem import ru.daemonlord.messenger.domain.chat.model.ChatItem
import ru.daemonlord.messenger.domain.chat.model.ChatMemberItem
import ru.daemonlord.messenger.domain.chat.model.DiscoverChatItem
import ru.daemonlord.messenger.domain.common.AppResult import ru.daemonlord.messenger.domain.common.AppResult
interface ChatRepository { interface ChatRepository {
@@ -12,5 +15,23 @@ interface ChatRepository {
suspend fun refreshChat(chatId: Long): AppResult<Unit> suspend fun refreshChat(chatId: Long): AppResult<Unit>
suspend fun createInviteLink(chatId: Long): AppResult<ChatInviteLink> suspend fun createInviteLink(chatId: Long): AppResult<ChatInviteLink>
suspend fun joinByInvite(token: String): AppResult<ChatItem> suspend fun joinByInvite(token: String): AppResult<ChatItem>
suspend fun createChat(
type: String,
title: String?,
isPublic: Boolean,
handle: String?,
description: String?,
memberIds: List<Long>,
): AppResult<ChatItem>
suspend fun discoverChats(query: String?): AppResult<List<DiscoverChatItem>>
suspend fun joinChat(chatId: Long): AppResult<ChatItem>
suspend fun leaveChat(chatId: Long): AppResult<Unit>
suspend fun listMembers(chatId: Long): AppResult<List<ChatMemberItem>>
suspend fun listBans(chatId: Long): AppResult<List<ChatBanItem>>
suspend fun addMember(chatId: Long, userId: Long): AppResult<ChatMemberItem>
suspend fun updateMemberRole(chatId: Long, userId: Long, role: String): AppResult<ChatMemberItem>
suspend fun removeMember(chatId: Long, userId: Long): AppResult<Unit>
suspend fun banMember(chatId: Long, userId: Long): AppResult<Unit>
suspend fun unbanMember(chatId: Long, userId: Long): AppResult<Unit>
suspend fun deleteChat(chatId: Long) suspend fun deleteChat(chatId: Long)
} }

View File

@@ -33,10 +33,14 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
@@ -77,6 +81,18 @@ fun ChatListRoute(
onOpenSettings = onOpenSettings, onOpenSettings = onOpenSettings,
onOpenProfile = onOpenProfile, onOpenProfile = onOpenProfile,
onOpenChat = onOpenChat, onOpenChat = onOpenChat,
onCreateGroup = viewModel::createGroup,
onCreateChannel = viewModel::createChannel,
onDiscoverChats = viewModel::discoverChats,
onJoinDiscoveredChat = viewModel::joinChat,
onLeaveChat = viewModel::leaveChat,
onSelectManageChat = viewModel::onManagementChatSelected,
onCreateInvite = viewModel::createInvite,
onAddMember = viewModel::addMember,
onUpdateMemberRole = viewModel::updateMemberRole,
onRemoveMember = viewModel::removeMember,
onBanMember = viewModel::banMember,
onUnbanMember = viewModel::unbanMember,
) )
} }
@@ -91,7 +107,29 @@ fun ChatListScreen(
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onOpenProfile: () -> Unit, onOpenProfile: () -> Unit,
onOpenChat: (Long) -> Unit, onOpenChat: (Long) -> Unit,
onCreateGroup: (String, List<Long>) -> Unit,
onCreateChannel: (String, String, String?) -> Unit,
onDiscoverChats: (String?) -> Unit,
onJoinDiscoveredChat: (Long) -> Unit,
onLeaveChat: (Long) -> Unit,
onSelectManageChat: (Long?) -> Unit,
onCreateInvite: (Long) -> Unit,
onAddMember: (Long, Long) -> Unit,
onUpdateMemberRole: (Long, Long, String) -> Unit,
onRemoveMember: (Long, Long) -> Unit,
onBanMember: (Long, Long) -> Unit,
onUnbanMember: (Long, Long) -> Unit,
) { ) {
var managementExpanded by remember { mutableStateOf(false) }
var createTitle by remember { mutableStateOf("") }
var createMemberIds by remember { mutableStateOf("") }
var createHandle by remember { mutableStateOf("") }
var createDescription by remember { mutableStateOf("") }
var discoverQuery by remember { mutableStateOf("") }
var selectedManageChatIdText by remember { mutableStateOf("") }
var manageUserIdText by remember { mutableStateOf("") }
var manageRoleText by remember { mutableStateOf("member") }
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -196,12 +234,12 @@ fun ChatListScreen(
} }
} }
Button( Button(
onClick = {}, onClick = { managementExpanded = !managementExpanded },
modifier = Modifier modifier = Modifier
.align(Alignment.BottomEnd) .align(Alignment.BottomEnd)
.padding(end = 16.dp, bottom = 88.dp), .padding(end = 16.dp, bottom = 88.dp),
) { ) {
Text("+") Text(if (managementExpanded) "×" else "+")
} }
Surface( Surface(
modifier = Modifier modifier = Modifier
@@ -221,6 +259,188 @@ fun ChatListScreen(
} }
} }
} }
if (managementExpanded) {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.95f),
shape = RoundedCornerShape(14.dp),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("Chat management", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = createTitle,
onValueChange = { createTitle = it },
label = { Text("Title") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = createMemberIds,
onValueChange = { createMemberIds = it },
label = { Text("Member IDs (comma, optional)") },
modifier = Modifier.fillMaxWidth(),
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = {
val ids = createMemberIds
.split(',')
.mapNotNull { it.trim().toLongOrNull() }
onCreateGroup(createTitle, ids)
},
) { Text("Create group") }
Button(
onClick = { onLeaveChat(state.selectedManageChatId ?: return@Button) },
enabled = state.selectedManageChatId != null,
) { Text("Leave chat") }
}
OutlinedTextField(
value = createHandle,
onValueChange = { createHandle = it },
label = { Text("Channel handle") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = createDescription,
onValueChange = { createDescription = it },
label = { Text("Channel description") },
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = { onCreateChannel(createTitle, createHandle, createDescription) },
modifier = Modifier.fillMaxWidth(),
) { Text("Create public channel") }
OutlinedTextField(
value = discoverQuery,
onValueChange = { discoverQuery = it },
label = { Text("Global discover query") },
modifier = Modifier.fillMaxWidth(),
)
Button(onClick = { onDiscoverChats(discoverQuery) }) { Text("Discover chats") }
if (state.discoverChats.isNotEmpty()) {
state.discoverChats.take(8).forEach { chat ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("${chat.displayTitle} (${chat.type})")
Button(
onClick = { onJoinDiscoveredChat(chat.id) },
enabled = !chat.isMember,
) {
Text(if (chat.isMember) "Joined" else "Join")
}
}
}
}
OutlinedTextField(
value = selectedManageChatIdText,
onValueChange = { selectedManageChatIdText = it.filter(Char::isDigit) },
label = { Text("Manage chat ID") },
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = {
val id = selectedManageChatIdText.toLongOrNull()
onSelectManageChat(id)
},
) { Text("Load members/bans") }
if (state.selectedManageChatId != null) {
Button(onClick = { onCreateInvite(state.selectedManageChatId) }) { Text("Create/regenerate invite") }
}
val visibilityHint = when {
state.selectedManageChatId == null -> "Select chat to manage members."
else -> "Member visibility: admin tools available only in selected chat context."
}
Text(visibilityHint, style = MaterialTheme.typography.bodySmall)
OutlinedTextField(
value = manageUserIdText,
onValueChange = { manageUserIdText = it.filter(Char::isDigit) },
label = { Text("User ID for admin action") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = manageRoleText,
onValueChange = { manageRoleText = it },
label = { Text("Role (owner/admin/member)") },
modifier = Modifier.fillMaxWidth(),
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = {
val chatId = state.selectedManageChatId ?: return@Button
val userId = manageUserIdText.toLongOrNull() ?: return@Button
onAddMember(chatId, userId)
},
enabled = state.selectedManageChatId != null,
) { Text("Add") }
Button(
onClick = {
val chatId = state.selectedManageChatId ?: return@Button
val userId = manageUserIdText.toLongOrNull() ?: return@Button
onUpdateMemberRole(chatId, userId, manageRoleText)
},
enabled = state.selectedManageChatId != null,
) { Text("Role") }
Button(
onClick = {
val chatId = state.selectedManageChatId ?: return@Button
val userId = manageUserIdText.toLongOrNull() ?: return@Button
onRemoveMember(chatId, userId)
},
enabled = state.selectedManageChatId != null,
) { Text("Remove") }
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = {
val chatId = state.selectedManageChatId ?: return@Button
val userId = manageUserIdText.toLongOrNull() ?: return@Button
onBanMember(chatId, userId)
},
enabled = state.selectedManageChatId != null,
) { Text("Ban") }
Button(
onClick = {
val chatId = state.selectedManageChatId ?: return@Button
val userId = manageUserIdText.toLongOrNull() ?: return@Button
onUnbanMember(chatId, userId)
},
enabled = state.selectedManageChatId != null,
) { Text("Unban") }
}
if (state.isManagementLoading) {
CircularProgressIndicator()
}
if (!state.managementMessage.isNullOrBlank()) {
Text(state.managementMessage, color = MaterialTheme.colorScheme.primary)
}
if (state.members.isNotEmpty()) {
Text("Members:", fontWeight = FontWeight.SemiBold)
state.members.take(6).forEach { member ->
Text("${member.userId}: ${member.name} (${member.role})")
}
}
if (state.bans.isNotEmpty()) {
Text("Bans:", fontWeight = FontWeight.SemiBold)
state.bans.take(6).forEach { ban ->
Text("${ban.userId}: ${ban.name}")
}
}
}
}
}
} }
} }

View File

@@ -1,6 +1,9 @@
package ru.daemonlord.messenger.ui.chats package ru.daemonlord.messenger.ui.chats
import ru.daemonlord.messenger.domain.chat.model.ChatItem import ru.daemonlord.messenger.domain.chat.model.ChatItem
import ru.daemonlord.messenger.domain.chat.model.ChatBanItem
import ru.daemonlord.messenger.domain.chat.model.ChatMemberItem
import ru.daemonlord.messenger.domain.chat.model.DiscoverChatItem
data class ChatListUiState( data class ChatListUiState(
val selectedTab: ChatTab = ChatTab.ALL, val selectedTab: ChatTab = ChatTab.ALL,
@@ -14,4 +17,10 @@ data class ChatListUiState(
val archivedUnreadCount: Int = 0, val archivedUnreadCount: Int = 0,
val isJoiningInvite: Boolean = false, val isJoiningInvite: Boolean = false,
val pendingOpenChatId: Long? = null, val pendingOpenChatId: Long? = null,
val discoverChats: List<DiscoverChatItem> = emptyList(),
val selectedManageChatId: Long? = null,
val members: List<ChatMemberItem> = emptyList(),
val bans: List<ChatBanItem> = emptyList(),
val isManagementLoading: Boolean = false,
val managementMessage: String? = null,
) )

View File

@@ -3,6 +3,7 @@ package ru.daemonlord.messenger.ui.chats
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -13,6 +14,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.daemonlord.messenger.domain.chat.model.ChatItem import ru.daemonlord.messenger.domain.chat.model.ChatItem
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
import ru.daemonlord.messenger.domain.chat.usecase.JoinByInviteUseCase import ru.daemonlord.messenger.domain.chat.usecase.JoinByInviteUseCase
import ru.daemonlord.messenger.domain.chat.usecase.ObserveChatsUseCase import ru.daemonlord.messenger.domain.chat.usecase.ObserveChatsUseCase
import ru.daemonlord.messenger.domain.chat.usecase.RefreshChatsUseCase import ru.daemonlord.messenger.domain.chat.usecase.RefreshChatsUseCase
@@ -22,11 +24,13 @@ import ru.daemonlord.messenger.domain.realtime.usecase.HandleRealtimeEventsUseCa
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@OptIn(ExperimentalCoroutinesApi::class)
class ChatListViewModel @Inject constructor( class ChatListViewModel @Inject constructor(
private val observeChatsUseCase: ObserveChatsUseCase, private val observeChatsUseCase: ObserveChatsUseCase,
private val refreshChatsUseCase: RefreshChatsUseCase, private val refreshChatsUseCase: RefreshChatsUseCase,
private val joinByInviteUseCase: JoinByInviteUseCase, private val joinByInviteUseCase: JoinByInviteUseCase,
private val handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase, private val handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase,
private val chatRepository: ChatRepository,
) : ViewModel() { ) : ViewModel() {
private val selectedTab = MutableStateFlow(ChatTab.ALL) private val selectedTab = MutableStateFlow(ChatTab.ALL)
@@ -101,6 +105,157 @@ class ChatListViewModel @Inject constructor(
_uiState.update { it.copy(pendingOpenChatId = null) } _uiState.update { it.copy(pendingOpenChatId = null) }
} }
fun onManagementChatSelected(chatId: Long?) {
_uiState.update { it.copy(selectedManageChatId = chatId) }
if (chatId != null) {
loadMembersAndBans(chatId)
}
}
fun discoverChats(query: String?) {
viewModelScope.launch {
_uiState.update { it.copy(isManagementLoading = true, errorMessage = null) }
when (val result = chatRepository.discoverChats(query = query?.trim()?.ifBlank { null })) {
is AppResult.Success -> _uiState.update {
it.copy(
isManagementLoading = false,
discoverChats = result.data,
)
}
is AppResult.Error -> _uiState.update {
it.copy(
isManagementLoading = false,
errorMessage = result.reason.toUiMessage(),
)
}
}
}
}
fun createGroup(title: String, memberIds: List<Long>) {
createChatInternal(
type = "group",
title = title,
isPublic = false,
handle = null,
description = null,
memberIds = memberIds,
successMessage = "Group created.",
)
}
fun createChannel(title: String, handle: String, description: String?) {
createChatInternal(
type = "channel",
title = title,
isPublic = true,
handle = handle,
description = description,
memberIds = emptyList(),
successMessage = "Channel created.",
)
}
fun joinChat(chatId: Long) {
viewModelScope.launch {
when (val result = chatRepository.joinChat(chatId = chatId)) {
is AppResult.Success -> {
_uiState.update {
it.copy(
managementMessage = "Joined chat.",
pendingOpenChatId = result.data.id,
)
}
refreshCurrentTab(forceRefresh = true)
}
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
}
}
}
fun leaveChat(chatId: Long) {
viewModelScope.launch {
when (val result = chatRepository.leaveChat(chatId = chatId)) {
is AppResult.Success -> {
_uiState.update { it.copy(managementMessage = "Left chat.") }
refreshCurrentTab(forceRefresh = true)
}
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
}
}
}
fun createInvite(chatId: Long) {
viewModelScope.launch {
when (val result = chatRepository.createInviteLink(chatId = chatId)) {
is AppResult.Success -> _uiState.update {
it.copy(managementMessage = "Invite: ${result.data.inviteUrl}")
}
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
}
}
}
fun addMember(chatId: Long, userId: Long) {
viewModelScope.launch {
when (val result = chatRepository.addMember(chatId = chatId, userId = userId)) {
is AppResult.Success -> {
_uiState.update { it.copy(managementMessage = "Added ${result.data.name}") }
loadMembersAndBans(chatId)
}
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
}
}
}
fun updateMemberRole(chatId: Long, userId: Long, role: String) {
viewModelScope.launch {
when (val result = chatRepository.updateMemberRole(chatId = chatId, userId = userId, role = role)) {
is AppResult.Success -> {
_uiState.update { it.copy(managementMessage = "Role updated: ${result.data.name} -> ${result.data.role}") }
loadMembersAndBans(chatId)
}
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
}
}
}
fun removeMember(chatId: Long, userId: Long) {
viewModelScope.launch {
when (val result = chatRepository.removeMember(chatId = chatId, userId = userId)) {
is AppResult.Success -> {
_uiState.update { it.copy(managementMessage = "Member removed.") }
loadMembersAndBans(chatId)
}
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
}
}
}
fun banMember(chatId: Long, userId: Long) {
viewModelScope.launch {
when (val result = chatRepository.banMember(chatId = chatId, userId = userId)) {
is AppResult.Success -> {
_uiState.update { it.copy(managementMessage = "Member banned.") }
loadMembersAndBans(chatId)
}
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
}
}
}
fun unbanMember(chatId: Long, userId: Long) {
viewModelScope.launch {
when (val result = chatRepository.unbanMember(chatId = chatId, userId = userId)) {
is AppResult.Success -> {
_uiState.update { it.copy(managementMessage = "Member unbanned.") }
loadMembersAndBans(chatId)
}
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
}
}
}
private fun observeChatStream() { private fun observeChatStream() {
viewModelScope.launch { viewModelScope.launch {
val archiveStatsFlow = observeChatsUseCase(archived = true) val archiveStatsFlow = observeChatsUseCase(archived = true)
@@ -129,12 +284,74 @@ class ChatListViewModel @Inject constructor(
chats = filtered, chats = filtered,
archivedChatsCount = stats.first, archivedChatsCount = stats.first,
archivedUnreadCount = stats.second, archivedUnreadCount = stats.second,
managementMessage = null,
) )
} }
} }
} }
} }
private fun createChatInternal(
type: String,
title: String,
isPublic: Boolean,
handle: String?,
description: String?,
memberIds: List<Long>,
successMessage: String,
) {
val normalizedTitle = title.trim()
if (normalizedTitle.isBlank()) {
_uiState.update { it.copy(errorMessage = "Title is required.") }
return
}
viewModelScope.launch {
when (
val result = chatRepository.createChat(
type = type,
title = normalizedTitle,
isPublic = isPublic,
handle = handle?.trim()?.ifBlank { null },
description = description?.trim()?.ifBlank { null },
memberIds = memberIds,
)
) {
is AppResult.Success -> {
_uiState.update {
it.copy(
managementMessage = successMessage,
pendingOpenChatId = result.data.id,
)
}
refreshCurrentTab(forceRefresh = true)
}
is AppResult.Error -> _uiState.update {
it.copy(errorMessage = result.reason.toUiMessage())
}
}
}
}
private fun loadMembersAndBans(chatId: Long) {
viewModelScope.launch {
_uiState.update { it.copy(isManagementLoading = true, errorMessage = null) }
val membersResult = chatRepository.listMembers(chatId = chatId)
val bansResult = chatRepository.listBans(chatId = chatId)
_uiState.update {
it.copy(
isManagementLoading = false,
members = (membersResult as? AppResult.Success)?.data ?: emptyList(),
bans = (bansResult as? AppResult.Success)?.data ?: emptyList(),
errorMessage = listOf(membersResult, bansResult)
.filterIsInstance<AppResult.Error>()
.firstOrNull()
?.reason
?.toUiMessage(),
)
}
}
}
private fun refreshCurrentTab(forceRefresh: Boolean = false) { private fun refreshCurrentTab(forceRefresh: Boolean = false) {
viewModelScope.launch { viewModelScope.launch {
_uiState.update { _uiState.update {

View File

@@ -74,13 +74,13 @@
- [x] Единый global audio focus (1 источник звука) - [x] Единый global audio focus (1 источник звука)
## 10. Группы/каналы ## 10. Группы/каналы
- [ ] Create group/channel - [x] Create group/channel
- [ ] Join/leave - [x] Join/leave
- [ ] Invite link (create/regenerate/join) - [x] Invite link (create/regenerate/join)
- [x] Roles owner/admin/member - [x] Roles owner/admin/member
- [ ] Admin actions: add/remove/ban/unban/promote/demote - [x] Admin actions: add/remove/ban/unban/promote/demote
- [x] Ограничения канала: писать только owner/admin - [x] Ограничения канала: писать только owner/admin
- [ ] Member visibility rules (скрытие списков/действий) - [x] Member visibility rules (скрытие списков/действий)
## 11. Поиск ## 11. Поиск
- [ ] Глобальный поиск: users/chats/messages - [ ] Глобальный поиск: users/chats/messages