android: add group channel invite and admin management baseline
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?,
|
||||
)
|
||||
@@ -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?,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user