diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index 078b2ee..95e8af3 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -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. diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/api/ChatApiService.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/api/ChatApiService.kt index 35a4b20..28b6864 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/api/ChatApiService.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/api/ChatApiService.kt @@ -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 + + @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 + + @GET("/api/v1/chats/{chat_id}/bans") + suspend fun listBans( + @Path("chat_id") chatId: Long, + ): List + + @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, + ) } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/dto/ChatDtos.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/dto/ChatDtos.kt index 391d18e..4225455 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/dto/ChatDtos.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/dto/ChatDtos.kt @@ -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 = 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, +) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/repository/NetworkChatRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/repository/NetworkChatRepository.kt index 70741f8..baa139a 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/repository/NetworkChatRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/repository/NetworkChatRepository.kt @@ -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, + ): AppResult = 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> = 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 = 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 = 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> = 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> = 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 = 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 = 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 = 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 = 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 = 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, + ) + } } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/model/ChatBanItem.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/model/ChatBanItem.kt new file mode 100644 index 0000000..252c944 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/model/ChatBanItem.kt @@ -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?, +) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/model/ChatMemberItem.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/model/ChatMemberItem.kt new file mode 100644 index 0000000..4121859 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/model/ChatMemberItem.kt @@ -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?, +) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/model/DiscoverChatItem.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/model/DiscoverChatItem.kt new file mode 100644 index 0000000..033a1cb --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/model/DiscoverChatItem.kt @@ -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, +) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/repository/ChatRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/repository/ChatRepository.kt index 2e53eb9..f3798ab 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/repository/ChatRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/repository/ChatRepository.kt @@ -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 suspend fun createInviteLink(chatId: Long): AppResult suspend fun joinByInvite(token: String): AppResult + suspend fun createChat( + type: String, + title: String?, + isPublic: Boolean, + handle: String?, + description: String?, + memberIds: List, + ): AppResult + suspend fun discoverChats(query: String?): AppResult> + suspend fun joinChat(chatId: Long): AppResult + suspend fun leaveChat(chatId: Long): AppResult + suspend fun listMembers(chatId: Long): AppResult> + suspend fun listBans(chatId: Long): AppResult> + suspend fun addMember(chatId: Long, userId: Long): AppResult + suspend fun updateMemberRole(chatId: Long, userId: Long, role: String): AppResult + suspend fun removeMember(chatId: Long, userId: Long): AppResult + suspend fun banMember(chatId: Long, userId: Long): AppResult + suspend fun unbanMember(chatId: Long, userId: Long): AppResult suspend fun deleteChat(chatId: Long) } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt index eb417b4..8bfb350 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt @@ -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) -> 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}") + } + } + } + } + } } } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListUiState.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListUiState.kt index 9ae02d3..90df168 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListUiState.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListUiState.kt @@ -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 = emptyList(), + val selectedManageChatId: Long? = null, + val members: List = emptyList(), + val bans: List = emptyList(), + val isManagementLoading: Boolean = false, + val managementMessage: String? = null, ) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListViewModel.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListViewModel.kt index 502b299..f0748a7 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListViewModel.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListViewModel.kt @@ -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) { + 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, + 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() + .firstOrNull() + ?.reason + ?.toUiMessage(), + ) + } + } + } + private fun refreshCurrentTab(forceRefresh: Boolean = false) { viewModelScope.launch { _uiState.update { diff --git a/docs/android-checklist.md b/docs/android-checklist.md index 261034c..fc145f4 100644 --- a/docs/android-checklist.md +++ b/docs/android-checklist.md @@ -74,13 +74,13 @@ - [x] Единый global audio focus (1 источник звука) ## 10. Группы/каналы -- [ ] Create group/channel -- [ ] Join/leave -- [ ] Invite link (create/regenerate/join) +- [x] Create group/channel +- [x] Join/leave +- [x] Invite link (create/regenerate/join) - [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 -- [ ] Member visibility rules (скрытие списков/действий) +- [x] Member visibility rules (скрытие списков/действий) ## 11. Поиск - [ ] Глобальный поиск: users/chats/messages