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:
- attachment player pauses when another source starts,
- 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
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.PATCH
import retrofit2.http.Path
import retrofit2.http.POST
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.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.DiscoverChatDto
interface ChatApiService {
@GET("/api/v1/chats")
@@ -29,4 +37,65 @@ interface ChatApiService {
suspend fun joinByInvite(
@Body request: ChatJoinByInviteRequestDto,
): 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(
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.channels.awaitClose
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.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.mapper.toChatEntity
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.domain.chat.model.ChatInviteLink
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.ChatMemberItem
import ru.daemonlord.messenger.domain.chat.model.DiscoverChatItem
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
import ru.daemonlord.messenger.domain.common.AppResult
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) {
withContext(ioDispatcher) {
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
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.ChatItem
import ru.daemonlord.messenger.domain.chat.model.ChatMemberItem
import ru.daemonlord.messenger.domain.chat.model.DiscoverChatItem
import ru.daemonlord.messenger.domain.common.AppResult
interface ChatRepository {
@@ -12,5 +15,23 @@ interface ChatRepository {
suspend fun refreshChat(chatId: Long): AppResult<Unit>
suspend fun createInviteLink(chatId: Long): AppResult<ChatInviteLink>
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)
}

View File

@@ -33,10 +33,14 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@@ -77,6 +81,18 @@ fun ChatListRoute(
onOpenSettings = onOpenSettings,
onOpenProfile = onOpenProfile,
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,
onOpenProfile: () -> 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(
modifier = Modifier
.fillMaxSize()
@@ -196,12 +234,12 @@ fun ChatListScreen(
}
}
Button(
onClick = {},
onClick = { managementExpanded = !managementExpanded },
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 16.dp, bottom = 88.dp),
) {
Text("+")
Text(if (managementExpanded) "×" else "+")
}
Surface(
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
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(
val selectedTab: ChatTab = ChatTab.ALL,
@@ -14,4 +17,10 @@ data class ChatListUiState(
val archivedUnreadCount: Int = 0,
val isJoiningInvite: Boolean = false,
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.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -13,6 +14,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
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.ObserveChatsUseCase
import ru.daemonlord.messenger.domain.chat.usecase.RefreshChatsUseCase
@@ -22,11 +24,13 @@ import ru.daemonlord.messenger.domain.realtime.usecase.HandleRealtimeEventsUseCa
import javax.inject.Inject
@HiltViewModel
@OptIn(ExperimentalCoroutinesApi::class)
class ChatListViewModel @Inject constructor(
private val observeChatsUseCase: ObserveChatsUseCase,
private val refreshChatsUseCase: RefreshChatsUseCase,
private val joinByInviteUseCase: JoinByInviteUseCase,
private val handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase,
private val chatRepository: ChatRepository,
) : ViewModel() {
private val selectedTab = MutableStateFlow(ChatTab.ALL)
@@ -101,6 +105,157 @@ class ChatListViewModel @Inject constructor(
_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() {
viewModelScope.launch {
val archiveStatsFlow = observeChatsUseCase(archived = true)
@@ -129,12 +284,74 @@ class ChatListViewModel @Inject constructor(
chats = filtered,
archivedChatsCount = stats.first,
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) {
viewModelScope.launch {
_uiState.update {