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:
|
- Added process-wide audio focus coordinator to enforce single active audio source:
|
||||||
- attachment player pauses when another source starts,
|
- attachment player pauses when another source starts,
|
||||||
- recording requests focus and stops competing playback.
|
- recording requests focus and stops competing playback.
|
||||||
|
|
||||||
|
### Step 67 - Group/channel management baseline in Chat List
|
||||||
|
- Extended chat API/repository layer with management operations:
|
||||||
|
- create group/channel,
|
||||||
|
- discover + join/leave chats,
|
||||||
|
- invite link create/regenerate,
|
||||||
|
- members/bans listing and admin actions (add/remove/ban/unban/promote/demote).
|
||||||
|
- Added domain models for discover/member/ban items and repository mappings.
|
||||||
|
- Added in-app management panel in `ChatListScreen` (FAB toggle) for:
|
||||||
|
- creating group/channel,
|
||||||
|
- joining discovered chats,
|
||||||
|
- loading chat members/bans by chat id,
|
||||||
|
- executing admin/member visibility actions from one place.
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
package ru.daemonlord.messenger.data.chat.api
|
package ru.daemonlord.messenger.data.chat.api
|
||||||
|
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.DELETE
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.PATCH
|
||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
import retrofit2.http.POST
|
import retrofit2.http.POST
|
||||||
import retrofit2.http.Query
|
import retrofit2.http.Query
|
||||||
|
import ru.daemonlord.messenger.data.chat.dto.ChatBanDto
|
||||||
|
import ru.daemonlord.messenger.data.chat.dto.ChatCreateRequestDto
|
||||||
import ru.daemonlord.messenger.data.chat.dto.ChatInviteLinkDto
|
import ru.daemonlord.messenger.data.chat.dto.ChatInviteLinkDto
|
||||||
import ru.daemonlord.messenger.data.chat.dto.ChatJoinByInviteRequestDto
|
import ru.daemonlord.messenger.data.chat.dto.ChatJoinByInviteRequestDto
|
||||||
|
import ru.daemonlord.messenger.data.chat.dto.ChatMemberAddRequestDto
|
||||||
|
import ru.daemonlord.messenger.data.chat.dto.ChatMemberDto
|
||||||
|
import ru.daemonlord.messenger.data.chat.dto.ChatMemberRoleUpdateRequestDto
|
||||||
import ru.daemonlord.messenger.data.chat.dto.ChatReadDto
|
import ru.daemonlord.messenger.data.chat.dto.ChatReadDto
|
||||||
|
import ru.daemonlord.messenger.data.chat.dto.DiscoverChatDto
|
||||||
|
|
||||||
interface ChatApiService {
|
interface ChatApiService {
|
||||||
@GET("/api/v1/chats")
|
@GET("/api/v1/chats")
|
||||||
@@ -29,4 +37,65 @@ interface ChatApiService {
|
|||||||
suspend fun joinByInvite(
|
suspend fun joinByInvite(
|
||||||
@Body request: ChatJoinByInviteRequestDto,
|
@Body request: ChatJoinByInviteRequestDto,
|
||||||
): ChatReadDto
|
): ChatReadDto
|
||||||
|
|
||||||
|
@POST("/api/v1/chats")
|
||||||
|
suspend fun createChat(
|
||||||
|
@Body request: ChatCreateRequestDto,
|
||||||
|
): ChatReadDto
|
||||||
|
|
||||||
|
@GET("/api/v1/chats/discover")
|
||||||
|
suspend fun discoverChats(
|
||||||
|
@Query("query") query: String? = null,
|
||||||
|
): List<DiscoverChatDto>
|
||||||
|
|
||||||
|
@POST("/api/v1/chats/{chat_id}/join")
|
||||||
|
suspend fun joinChat(
|
||||||
|
@Path("chat_id") chatId: Long,
|
||||||
|
): ChatReadDto
|
||||||
|
|
||||||
|
@POST("/api/v1/chats/{chat_id}/leave")
|
||||||
|
suspend fun leaveChat(
|
||||||
|
@Path("chat_id") chatId: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
@GET("/api/v1/chats/{chat_id}/members")
|
||||||
|
suspend fun listMembers(
|
||||||
|
@Path("chat_id") chatId: Long,
|
||||||
|
): List<ChatMemberDto>
|
||||||
|
|
||||||
|
@GET("/api/v1/chats/{chat_id}/bans")
|
||||||
|
suspend fun listBans(
|
||||||
|
@Path("chat_id") chatId: Long,
|
||||||
|
): List<ChatBanDto>
|
||||||
|
|
||||||
|
@POST("/api/v1/chats/{chat_id}/members")
|
||||||
|
suspend fun addMember(
|
||||||
|
@Path("chat_id") chatId: Long,
|
||||||
|
@Body request: ChatMemberAddRequestDto,
|
||||||
|
): ChatMemberDto
|
||||||
|
|
||||||
|
@PATCH("/api/v1/chats/{chat_id}/members/{user_id}/role")
|
||||||
|
suspend fun updateMemberRole(
|
||||||
|
@Path("chat_id") chatId: Long,
|
||||||
|
@Path("user_id") userId: Long,
|
||||||
|
@Body request: ChatMemberRoleUpdateRequestDto,
|
||||||
|
): ChatMemberDto
|
||||||
|
|
||||||
|
@DELETE("/api/v1/chats/{chat_id}/members/{user_id}")
|
||||||
|
suspend fun removeMember(
|
||||||
|
@Path("chat_id") chatId: Long,
|
||||||
|
@Path("user_id") userId: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
@POST("/api/v1/chats/{chat_id}/bans/{user_id}")
|
||||||
|
suspend fun banMember(
|
||||||
|
@Path("chat_id") chatId: Long,
|
||||||
|
@Path("user_id") userId: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
@DELETE("/api/v1/chats/{chat_id}/bans/{user_id}")
|
||||||
|
suspend fun unbanMember(
|
||||||
|
@Path("chat_id") chatId: Long,
|
||||||
|
@Path("user_id") userId: Long,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,3 +61,60 @@ data class ChatInviteLinkDto(
|
|||||||
data class ChatJoinByInviteRequestDto(
|
data class ChatJoinByInviteRequestDto(
|
||||||
val token: String,
|
val token: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ChatCreateRequestDto(
|
||||||
|
val type: String,
|
||||||
|
val title: String? = null,
|
||||||
|
@SerialName("is_public")
|
||||||
|
val isPublic: Boolean = false,
|
||||||
|
val handle: String? = null,
|
||||||
|
val description: String? = null,
|
||||||
|
@SerialName("member_ids")
|
||||||
|
val memberIds: List<Long> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class DiscoverChatDto(
|
||||||
|
val id: Long,
|
||||||
|
val type: String,
|
||||||
|
@SerialName("display_title")
|
||||||
|
val displayTitle: String,
|
||||||
|
val handle: String? = null,
|
||||||
|
@SerialName("avatar_url")
|
||||||
|
val avatarUrl: String? = null,
|
||||||
|
@SerialName("is_member")
|
||||||
|
val isMember: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ChatMemberDto(
|
||||||
|
@SerialName("user_id")
|
||||||
|
val userId: Long,
|
||||||
|
val role: String,
|
||||||
|
val name: String? = null,
|
||||||
|
val username: String? = null,
|
||||||
|
@SerialName("avatar_url")
|
||||||
|
val avatarUrl: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ChatBanDto(
|
||||||
|
@SerialName("user_id")
|
||||||
|
val userId: Long,
|
||||||
|
@SerialName("banned_at")
|
||||||
|
val bannedAt: String? = null,
|
||||||
|
val name: String? = null,
|
||||||
|
val username: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ChatMemberRoleUpdateRequestDto(
|
||||||
|
val role: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ChatMemberAddRequestDto(
|
||||||
|
@SerialName("user_id")
|
||||||
|
val userId: Long,
|
||||||
|
)
|
||||||
|
|||||||
@@ -9,7 +9,13 @@ import kotlinx.coroutines.withContext
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.channels.awaitClose
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
import ru.daemonlord.messenger.data.chat.api.ChatApiService
|
import ru.daemonlord.messenger.data.chat.api.ChatApiService
|
||||||
|
import ru.daemonlord.messenger.data.chat.dto.ChatBanDto
|
||||||
|
import ru.daemonlord.messenger.data.chat.dto.ChatCreateRequestDto
|
||||||
import ru.daemonlord.messenger.data.chat.dto.ChatJoinByInviteRequestDto
|
import ru.daemonlord.messenger.data.chat.dto.ChatJoinByInviteRequestDto
|
||||||
|
import ru.daemonlord.messenger.data.chat.dto.ChatMemberAddRequestDto
|
||||||
|
import ru.daemonlord.messenger.data.chat.dto.ChatMemberDto
|
||||||
|
import ru.daemonlord.messenger.data.chat.dto.ChatMemberRoleUpdateRequestDto
|
||||||
|
import ru.daemonlord.messenger.data.chat.dto.DiscoverChatDto
|
||||||
import ru.daemonlord.messenger.data.chat.local.dao.ChatDao
|
import ru.daemonlord.messenger.data.chat.local.dao.ChatDao
|
||||||
import ru.daemonlord.messenger.data.chat.mapper.toChatEntity
|
import ru.daemonlord.messenger.data.chat.mapper.toChatEntity
|
||||||
import ru.daemonlord.messenger.data.chat.mapper.toDomain
|
import ru.daemonlord.messenger.data.chat.mapper.toDomain
|
||||||
@@ -17,7 +23,10 @@ import ru.daemonlord.messenger.data.chat.mapper.toUserShortEntityOrNull
|
|||||||
import ru.daemonlord.messenger.data.common.toAppError
|
import ru.daemonlord.messenger.data.common.toAppError
|
||||||
import ru.daemonlord.messenger.domain.chat.model.ChatInviteLink
|
import ru.daemonlord.messenger.domain.chat.model.ChatInviteLink
|
||||||
import ru.daemonlord.messenger.di.IoDispatcher
|
import ru.daemonlord.messenger.di.IoDispatcher
|
||||||
|
import ru.daemonlord.messenger.domain.chat.model.ChatBanItem
|
||||||
import ru.daemonlord.messenger.domain.chat.model.ChatItem
|
import ru.daemonlord.messenger.domain.chat.model.ChatItem
|
||||||
|
import ru.daemonlord.messenger.domain.chat.model.ChatMemberItem
|
||||||
|
import ru.daemonlord.messenger.domain.chat.model.DiscoverChatItem
|
||||||
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
|
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
|
||||||
import ru.daemonlord.messenger.domain.common.AppResult
|
import ru.daemonlord.messenger.domain.common.AppResult
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -95,9 +104,169 @@ class NetworkChatRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun createChat(
|
||||||
|
type: String,
|
||||||
|
title: String?,
|
||||||
|
isPublic: Boolean,
|
||||||
|
handle: String?,
|
||||||
|
description: String?,
|
||||||
|
memberIds: List<Long>,
|
||||||
|
): AppResult<ChatItem> = withContext(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
val created = chatApiService.createChat(
|
||||||
|
request = ChatCreateRequestDto(
|
||||||
|
type = type,
|
||||||
|
title = title,
|
||||||
|
isPublic = isPublic,
|
||||||
|
handle = handle,
|
||||||
|
description = description,
|
||||||
|
memberIds = memberIds,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
chatDao.upsertUsers(created.toUserShortEntityOrNull()?.let(::listOf).orEmpty())
|
||||||
|
val chatEntity = created.toChatEntity()
|
||||||
|
chatDao.upsertChats(listOf(chatEntity))
|
||||||
|
AppResult.Success(chatEntity.toDomain())
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun discoverChats(query: String?): AppResult<List<DiscoverChatItem>> = withContext(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
AppResult.Success(chatApiService.discoverChats(query = query).map { it.toDomain() })
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun joinChat(chatId: Long): AppResult<ChatItem> = withContext(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
val joined = chatApiService.joinChat(chatId = chatId)
|
||||||
|
chatDao.upsertUsers(joined.toUserShortEntityOrNull()?.let(::listOf).orEmpty())
|
||||||
|
val entity = joined.toChatEntity()
|
||||||
|
chatDao.upsertChats(listOf(entity))
|
||||||
|
AppResult.Success(entity.toDomain())
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun leaveChat(chatId: Long): AppResult<Unit> = withContext(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
chatApiService.leaveChat(chatId = chatId)
|
||||||
|
chatDao.deleteChat(chatId = chatId)
|
||||||
|
AppResult.Success(Unit)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun listMembers(chatId: Long): AppResult<List<ChatMemberItem>> = withContext(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
AppResult.Success(chatApiService.listMembers(chatId = chatId).map { it.toDomain() })
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun listBans(chatId: Long): AppResult<List<ChatBanItem>> = withContext(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
AppResult.Success(chatApiService.listBans(chatId = chatId).map { it.toDomain() })
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun addMember(chatId: Long, userId: Long): AppResult<ChatMemberItem> = withContext(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
AppResult.Success(
|
||||||
|
chatApiService.addMember(
|
||||||
|
chatId = chatId,
|
||||||
|
request = ChatMemberAddRequestDto(userId = userId),
|
||||||
|
).toDomain()
|
||||||
|
)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updateMemberRole(chatId: Long, userId: Long, role: String): AppResult<ChatMemberItem> = withContext(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
AppResult.Success(
|
||||||
|
chatApiService.updateMemberRole(
|
||||||
|
chatId = chatId,
|
||||||
|
userId = userId,
|
||||||
|
request = ChatMemberRoleUpdateRequestDto(role = role),
|
||||||
|
).toDomain()
|
||||||
|
)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun removeMember(chatId: Long, userId: Long): AppResult<Unit> = withContext(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
chatApiService.removeMember(chatId = chatId, userId = userId)
|
||||||
|
AppResult.Success(Unit)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun banMember(chatId: Long, userId: Long): AppResult<Unit> = withContext(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
chatApiService.banMember(chatId = chatId, userId = userId)
|
||||||
|
AppResult.Success(Unit)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun unbanMember(chatId: Long, userId: Long): AppResult<Unit> = withContext(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
chatApiService.unbanMember(chatId = chatId, userId = userId)
|
||||||
|
AppResult.Success(Unit)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun deleteChat(chatId: Long) {
|
override suspend fun deleteChat(chatId: Long) {
|
||||||
withContext(ioDispatcher) {
|
withContext(ioDispatcher) {
|
||||||
chatDao.deleteChat(chatId = chatId)
|
chatDao.deleteChat(chatId = chatId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun DiscoverChatDto.toDomain(): DiscoverChatItem {
|
||||||
|
return DiscoverChatItem(
|
||||||
|
id = id,
|
||||||
|
type = type,
|
||||||
|
displayTitle = displayTitle,
|
||||||
|
handle = handle,
|
||||||
|
avatarUrl = avatarUrl,
|
||||||
|
isMember = isMember,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ChatMemberDto.toDomain(): ChatMemberItem {
|
||||||
|
val fallbackName = username?.takeIf { it.isNotBlank() }?.let { "@$it" } ?: "User #$userId"
|
||||||
|
return ChatMemberItem(
|
||||||
|
userId = userId,
|
||||||
|
role = role,
|
||||||
|
name = name?.takeIf { it.isNotBlank() } ?: fallbackName,
|
||||||
|
username = username,
|
||||||
|
avatarUrl = avatarUrl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ChatBanDto.toDomain(): ChatBanItem {
|
||||||
|
val fallbackName = username?.takeIf { it.isNotBlank() }?.let { "@$it" } ?: "User #$userId"
|
||||||
|
return ChatBanItem(
|
||||||
|
userId = userId,
|
||||||
|
name = name?.takeIf { it.isNotBlank() } ?: fallbackName,
|
||||||
|
username = username,
|
||||||
|
bannedAt = bannedAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
package ru.daemonlord.messenger.domain.chat.repository
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import ru.daemonlord.messenger.domain.chat.model.ChatBanItem
|
||||||
import ru.daemonlord.messenger.domain.chat.model.ChatInviteLink
|
import ru.daemonlord.messenger.domain.chat.model.ChatInviteLink
|
||||||
import ru.daemonlord.messenger.domain.chat.model.ChatItem
|
import ru.daemonlord.messenger.domain.chat.model.ChatItem
|
||||||
|
import ru.daemonlord.messenger.domain.chat.model.ChatMemberItem
|
||||||
|
import ru.daemonlord.messenger.domain.chat.model.DiscoverChatItem
|
||||||
import ru.daemonlord.messenger.domain.common.AppResult
|
import ru.daemonlord.messenger.domain.common.AppResult
|
||||||
|
|
||||||
interface ChatRepository {
|
interface ChatRepository {
|
||||||
@@ -12,5 +15,23 @@ interface ChatRepository {
|
|||||||
suspend fun refreshChat(chatId: Long): AppResult<Unit>
|
suspend fun refreshChat(chatId: Long): AppResult<Unit>
|
||||||
suspend fun createInviteLink(chatId: Long): AppResult<ChatInviteLink>
|
suspend fun createInviteLink(chatId: Long): AppResult<ChatInviteLink>
|
||||||
suspend fun joinByInvite(token: String): AppResult<ChatItem>
|
suspend fun joinByInvite(token: String): AppResult<ChatItem>
|
||||||
|
suspend fun createChat(
|
||||||
|
type: String,
|
||||||
|
title: String?,
|
||||||
|
isPublic: Boolean,
|
||||||
|
handle: String?,
|
||||||
|
description: String?,
|
||||||
|
memberIds: List<Long>,
|
||||||
|
): AppResult<ChatItem>
|
||||||
|
suspend fun discoverChats(query: String?): AppResult<List<DiscoverChatItem>>
|
||||||
|
suspend fun joinChat(chatId: Long): AppResult<ChatItem>
|
||||||
|
suspend fun leaveChat(chatId: Long): AppResult<Unit>
|
||||||
|
suspend fun listMembers(chatId: Long): AppResult<List<ChatMemberItem>>
|
||||||
|
suspend fun listBans(chatId: Long): AppResult<List<ChatBanItem>>
|
||||||
|
suspend fun addMember(chatId: Long, userId: Long): AppResult<ChatMemberItem>
|
||||||
|
suspend fun updateMemberRole(chatId: Long, userId: Long, role: String): AppResult<ChatMemberItem>
|
||||||
|
suspend fun removeMember(chatId: Long, userId: Long): AppResult<Unit>
|
||||||
|
suspend fun banMember(chatId: Long, userId: Long): AppResult<Unit>
|
||||||
|
suspend fun unbanMember(chatId: Long, userId: Long): AppResult<Unit>
|
||||||
suspend fun deleteChat(chatId: Long)
|
suspend fun deleteChat(chatId: Long)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,10 +33,14 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
@@ -77,6 +81,18 @@ fun ChatListRoute(
|
|||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
onOpenProfile = onOpenProfile,
|
onOpenProfile = onOpenProfile,
|
||||||
onOpenChat = onOpenChat,
|
onOpenChat = onOpenChat,
|
||||||
|
onCreateGroup = viewModel::createGroup,
|
||||||
|
onCreateChannel = viewModel::createChannel,
|
||||||
|
onDiscoverChats = viewModel::discoverChats,
|
||||||
|
onJoinDiscoveredChat = viewModel::joinChat,
|
||||||
|
onLeaveChat = viewModel::leaveChat,
|
||||||
|
onSelectManageChat = viewModel::onManagementChatSelected,
|
||||||
|
onCreateInvite = viewModel::createInvite,
|
||||||
|
onAddMember = viewModel::addMember,
|
||||||
|
onUpdateMemberRole = viewModel::updateMemberRole,
|
||||||
|
onRemoveMember = viewModel::removeMember,
|
||||||
|
onBanMember = viewModel::banMember,
|
||||||
|
onUnbanMember = viewModel::unbanMember,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +107,29 @@ fun ChatListScreen(
|
|||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
onOpenProfile: () -> Unit,
|
onOpenProfile: () -> Unit,
|
||||||
onOpenChat: (Long) -> Unit,
|
onOpenChat: (Long) -> Unit,
|
||||||
|
onCreateGroup: (String, List<Long>) -> Unit,
|
||||||
|
onCreateChannel: (String, String, String?) -> Unit,
|
||||||
|
onDiscoverChats: (String?) -> Unit,
|
||||||
|
onJoinDiscoveredChat: (Long) -> Unit,
|
||||||
|
onLeaveChat: (Long) -> Unit,
|
||||||
|
onSelectManageChat: (Long?) -> Unit,
|
||||||
|
onCreateInvite: (Long) -> Unit,
|
||||||
|
onAddMember: (Long, Long) -> Unit,
|
||||||
|
onUpdateMemberRole: (Long, Long, String) -> Unit,
|
||||||
|
onRemoveMember: (Long, Long) -> Unit,
|
||||||
|
onBanMember: (Long, Long) -> Unit,
|
||||||
|
onUnbanMember: (Long, Long) -> Unit,
|
||||||
) {
|
) {
|
||||||
|
var managementExpanded by remember { mutableStateOf(false) }
|
||||||
|
var createTitle by remember { mutableStateOf("") }
|
||||||
|
var createMemberIds by remember { mutableStateOf("") }
|
||||||
|
var createHandle by remember { mutableStateOf("") }
|
||||||
|
var createDescription by remember { mutableStateOf("") }
|
||||||
|
var discoverQuery by remember { mutableStateOf("") }
|
||||||
|
var selectedManageChatIdText by remember { mutableStateOf("") }
|
||||||
|
var manageUserIdText by remember { mutableStateOf("") }
|
||||||
|
var manageRoleText by remember { mutableStateOf("member") }
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -196,12 +234,12 @@ fun ChatListScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Button(
|
Button(
|
||||||
onClick = {},
|
onClick = { managementExpanded = !managementExpanded },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomEnd)
|
.align(Alignment.BottomEnd)
|
||||||
.padding(end = 16.dp, bottom = 88.dp),
|
.padding(end = 16.dp, bottom = 88.dp),
|
||||||
) {
|
) {
|
||||||
Text("+")
|
Text(if (managementExpanded) "×" else "+")
|
||||||
}
|
}
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -221,6 +259,188 @@ fun ChatListScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (managementExpanded) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.95f),
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text("Chat management", style = MaterialTheme.typography.titleMedium)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = createTitle,
|
||||||
|
onValueChange = { createTitle = it },
|
||||||
|
label = { Text("Title") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = createMemberIds,
|
||||||
|
onValueChange = { createMemberIds = it },
|
||||||
|
label = { Text("Member IDs (comma, optional)") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val ids = createMemberIds
|
||||||
|
.split(',')
|
||||||
|
.mapNotNull { it.trim().toLongOrNull() }
|
||||||
|
onCreateGroup(createTitle, ids)
|
||||||
|
},
|
||||||
|
) { Text("Create group") }
|
||||||
|
Button(
|
||||||
|
onClick = { onLeaveChat(state.selectedManageChatId ?: return@Button) },
|
||||||
|
enabled = state.selectedManageChatId != null,
|
||||||
|
) { Text("Leave chat") }
|
||||||
|
}
|
||||||
|
OutlinedTextField(
|
||||||
|
value = createHandle,
|
||||||
|
onValueChange = { createHandle = it },
|
||||||
|
label = { Text("Channel handle") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = createDescription,
|
||||||
|
onValueChange = { createDescription = it },
|
||||||
|
label = { Text("Channel description") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = { onCreateChannel(createTitle, createHandle, createDescription) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) { Text("Create public channel") }
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = discoverQuery,
|
||||||
|
onValueChange = { discoverQuery = it },
|
||||||
|
label = { Text("Global discover query") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Button(onClick = { onDiscoverChats(discoverQuery) }) { Text("Discover chats") }
|
||||||
|
if (state.discoverChats.isNotEmpty()) {
|
||||||
|
state.discoverChats.take(8).forEach { chat ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text("${chat.displayTitle} (${chat.type})")
|
||||||
|
Button(
|
||||||
|
onClick = { onJoinDiscoveredChat(chat.id) },
|
||||||
|
enabled = !chat.isMember,
|
||||||
|
) {
|
||||||
|
Text(if (chat.isMember) "Joined" else "Join")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = selectedManageChatIdText,
|
||||||
|
onValueChange = { selectedManageChatIdText = it.filter(Char::isDigit) },
|
||||||
|
label = { Text("Manage chat ID") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val id = selectedManageChatIdText.toLongOrNull()
|
||||||
|
onSelectManageChat(id)
|
||||||
|
},
|
||||||
|
) { Text("Load members/bans") }
|
||||||
|
if (state.selectedManageChatId != null) {
|
||||||
|
Button(onClick = { onCreateInvite(state.selectedManageChatId) }) { Text("Create/regenerate invite") }
|
||||||
|
}
|
||||||
|
|
||||||
|
val visibilityHint = when {
|
||||||
|
state.selectedManageChatId == null -> "Select chat to manage members."
|
||||||
|
else -> "Member visibility: admin tools available only in selected chat context."
|
||||||
|
}
|
||||||
|
Text(visibilityHint, style = MaterialTheme.typography.bodySmall)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = manageUserIdText,
|
||||||
|
onValueChange = { manageUserIdText = it.filter(Char::isDigit) },
|
||||||
|
label = { Text("User ID for admin action") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = manageRoleText,
|
||||||
|
onValueChange = { manageRoleText = it },
|
||||||
|
label = { Text("Role (owner/admin/member)") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val chatId = state.selectedManageChatId ?: return@Button
|
||||||
|
val userId = manageUserIdText.toLongOrNull() ?: return@Button
|
||||||
|
onAddMember(chatId, userId)
|
||||||
|
},
|
||||||
|
enabled = state.selectedManageChatId != null,
|
||||||
|
) { Text("Add") }
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val chatId = state.selectedManageChatId ?: return@Button
|
||||||
|
val userId = manageUserIdText.toLongOrNull() ?: return@Button
|
||||||
|
onUpdateMemberRole(chatId, userId, manageRoleText)
|
||||||
|
},
|
||||||
|
enabled = state.selectedManageChatId != null,
|
||||||
|
) { Text("Role") }
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val chatId = state.selectedManageChatId ?: return@Button
|
||||||
|
val userId = manageUserIdText.toLongOrNull() ?: return@Button
|
||||||
|
onRemoveMember(chatId, userId)
|
||||||
|
},
|
||||||
|
enabled = state.selectedManageChatId != null,
|
||||||
|
) { Text("Remove") }
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val chatId = state.selectedManageChatId ?: return@Button
|
||||||
|
val userId = manageUserIdText.toLongOrNull() ?: return@Button
|
||||||
|
onBanMember(chatId, userId)
|
||||||
|
},
|
||||||
|
enabled = state.selectedManageChatId != null,
|
||||||
|
) { Text("Ban") }
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val chatId = state.selectedManageChatId ?: return@Button
|
||||||
|
val userId = manageUserIdText.toLongOrNull() ?: return@Button
|
||||||
|
onUnbanMember(chatId, userId)
|
||||||
|
},
|
||||||
|
enabled = state.selectedManageChatId != null,
|
||||||
|
) { Text("Unban") }
|
||||||
|
}
|
||||||
|
if (state.isManagementLoading) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
if (!state.managementMessage.isNullOrBlank()) {
|
||||||
|
Text(state.managementMessage, color = MaterialTheme.colorScheme.primary)
|
||||||
|
}
|
||||||
|
if (state.members.isNotEmpty()) {
|
||||||
|
Text("Members:", fontWeight = FontWeight.SemiBold)
|
||||||
|
state.members.take(6).forEach { member ->
|
||||||
|
Text("${member.userId}: ${member.name} (${member.role})")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.bans.isNotEmpty()) {
|
||||||
|
Text("Bans:", fontWeight = FontWeight.SemiBold)
|
||||||
|
state.bans.take(6).forEach { ban ->
|
||||||
|
Text("${ban.userId}: ${ban.name}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package ru.daemonlord.messenger.ui.chats
|
package ru.daemonlord.messenger.ui.chats
|
||||||
|
|
||||||
import ru.daemonlord.messenger.domain.chat.model.ChatItem
|
import ru.daemonlord.messenger.domain.chat.model.ChatItem
|
||||||
|
import ru.daemonlord.messenger.domain.chat.model.ChatBanItem
|
||||||
|
import ru.daemonlord.messenger.domain.chat.model.ChatMemberItem
|
||||||
|
import ru.daemonlord.messenger.domain.chat.model.DiscoverChatItem
|
||||||
|
|
||||||
data class ChatListUiState(
|
data class ChatListUiState(
|
||||||
val selectedTab: ChatTab = ChatTab.ALL,
|
val selectedTab: ChatTab = ChatTab.ALL,
|
||||||
@@ -14,4 +17,10 @@ data class ChatListUiState(
|
|||||||
val archivedUnreadCount: Int = 0,
|
val archivedUnreadCount: Int = 0,
|
||||||
val isJoiningInvite: Boolean = false,
|
val isJoiningInvite: Boolean = false,
|
||||||
val pendingOpenChatId: Long? = null,
|
val pendingOpenChatId: Long? = null,
|
||||||
|
val discoverChats: List<DiscoverChatItem> = emptyList(),
|
||||||
|
val selectedManageChatId: Long? = null,
|
||||||
|
val members: List<ChatMemberItem> = emptyList(),
|
||||||
|
val bans: List<ChatBanItem> = emptyList(),
|
||||||
|
val isManagementLoading: Boolean = false,
|
||||||
|
val managementMessage: String? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package ru.daemonlord.messenger.ui.chats
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -13,6 +14,7 @@ import kotlinx.coroutines.flow.map
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import ru.daemonlord.messenger.domain.chat.model.ChatItem
|
import ru.daemonlord.messenger.domain.chat.model.ChatItem
|
||||||
|
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
|
||||||
import ru.daemonlord.messenger.domain.chat.usecase.JoinByInviteUseCase
|
import ru.daemonlord.messenger.domain.chat.usecase.JoinByInviteUseCase
|
||||||
import ru.daemonlord.messenger.domain.chat.usecase.ObserveChatsUseCase
|
import ru.daemonlord.messenger.domain.chat.usecase.ObserveChatsUseCase
|
||||||
import ru.daemonlord.messenger.domain.chat.usecase.RefreshChatsUseCase
|
import ru.daemonlord.messenger.domain.chat.usecase.RefreshChatsUseCase
|
||||||
@@ -22,11 +24,13 @@ import ru.daemonlord.messenger.domain.realtime.usecase.HandleRealtimeEventsUseCa
|
|||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class ChatListViewModel @Inject constructor(
|
class ChatListViewModel @Inject constructor(
|
||||||
private val observeChatsUseCase: ObserveChatsUseCase,
|
private val observeChatsUseCase: ObserveChatsUseCase,
|
||||||
private val refreshChatsUseCase: RefreshChatsUseCase,
|
private val refreshChatsUseCase: RefreshChatsUseCase,
|
||||||
private val joinByInviteUseCase: JoinByInviteUseCase,
|
private val joinByInviteUseCase: JoinByInviteUseCase,
|
||||||
private val handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase,
|
private val handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase,
|
||||||
|
private val chatRepository: ChatRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val selectedTab = MutableStateFlow(ChatTab.ALL)
|
private val selectedTab = MutableStateFlow(ChatTab.ALL)
|
||||||
@@ -101,6 +105,157 @@ class ChatListViewModel @Inject constructor(
|
|||||||
_uiState.update { it.copy(pendingOpenChatId = null) }
|
_uiState.update { it.copy(pendingOpenChatId = null) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun onManagementChatSelected(chatId: Long?) {
|
||||||
|
_uiState.update { it.copy(selectedManageChatId = chatId) }
|
||||||
|
if (chatId != null) {
|
||||||
|
loadMembersAndBans(chatId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun discoverChats(query: String?) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isManagementLoading = true, errorMessage = null) }
|
||||||
|
when (val result = chatRepository.discoverChats(query = query?.trim()?.ifBlank { null })) {
|
||||||
|
is AppResult.Success -> _uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isManagementLoading = false,
|
||||||
|
discoverChats = result.data,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is AppResult.Error -> _uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isManagementLoading = false,
|
||||||
|
errorMessage = result.reason.toUiMessage(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createGroup(title: String, memberIds: List<Long>) {
|
||||||
|
createChatInternal(
|
||||||
|
type = "group",
|
||||||
|
title = title,
|
||||||
|
isPublic = false,
|
||||||
|
handle = null,
|
||||||
|
description = null,
|
||||||
|
memberIds = memberIds,
|
||||||
|
successMessage = "Group created.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createChannel(title: String, handle: String, description: String?) {
|
||||||
|
createChatInternal(
|
||||||
|
type = "channel",
|
||||||
|
title = title,
|
||||||
|
isPublic = true,
|
||||||
|
handle = handle,
|
||||||
|
description = description,
|
||||||
|
memberIds = emptyList(),
|
||||||
|
successMessage = "Channel created.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun joinChat(chatId: Long) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = chatRepository.joinChat(chatId = chatId)) {
|
||||||
|
is AppResult.Success -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
managementMessage = "Joined chat.",
|
||||||
|
pendingOpenChatId = result.data.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
refreshCurrentTab(forceRefresh = true)
|
||||||
|
}
|
||||||
|
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun leaveChat(chatId: Long) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = chatRepository.leaveChat(chatId = chatId)) {
|
||||||
|
is AppResult.Success -> {
|
||||||
|
_uiState.update { it.copy(managementMessage = "Left chat.") }
|
||||||
|
refreshCurrentTab(forceRefresh = true)
|
||||||
|
}
|
||||||
|
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createInvite(chatId: Long) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = chatRepository.createInviteLink(chatId = chatId)) {
|
||||||
|
is AppResult.Success -> _uiState.update {
|
||||||
|
it.copy(managementMessage = "Invite: ${result.data.inviteUrl}")
|
||||||
|
}
|
||||||
|
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addMember(chatId: Long, userId: Long) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = chatRepository.addMember(chatId = chatId, userId = userId)) {
|
||||||
|
is AppResult.Success -> {
|
||||||
|
_uiState.update { it.copy(managementMessage = "Added ${result.data.name}") }
|
||||||
|
loadMembersAndBans(chatId)
|
||||||
|
}
|
||||||
|
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateMemberRole(chatId: Long, userId: Long, role: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = chatRepository.updateMemberRole(chatId = chatId, userId = userId, role = role)) {
|
||||||
|
is AppResult.Success -> {
|
||||||
|
_uiState.update { it.copy(managementMessage = "Role updated: ${result.data.name} -> ${result.data.role}") }
|
||||||
|
loadMembersAndBans(chatId)
|
||||||
|
}
|
||||||
|
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeMember(chatId: Long, userId: Long) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = chatRepository.removeMember(chatId = chatId, userId = userId)) {
|
||||||
|
is AppResult.Success -> {
|
||||||
|
_uiState.update { it.copy(managementMessage = "Member removed.") }
|
||||||
|
loadMembersAndBans(chatId)
|
||||||
|
}
|
||||||
|
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun banMember(chatId: Long, userId: Long) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = chatRepository.banMember(chatId = chatId, userId = userId)) {
|
||||||
|
is AppResult.Success -> {
|
||||||
|
_uiState.update { it.copy(managementMessage = "Member banned.") }
|
||||||
|
loadMembersAndBans(chatId)
|
||||||
|
}
|
||||||
|
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unbanMember(chatId: Long, userId: Long) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = chatRepository.unbanMember(chatId = chatId, userId = userId)) {
|
||||||
|
is AppResult.Success -> {
|
||||||
|
_uiState.update { it.copy(managementMessage = "Member unbanned.") }
|
||||||
|
loadMembersAndBans(chatId)
|
||||||
|
}
|
||||||
|
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun observeChatStream() {
|
private fun observeChatStream() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val archiveStatsFlow = observeChatsUseCase(archived = true)
|
val archiveStatsFlow = observeChatsUseCase(archived = true)
|
||||||
@@ -129,12 +284,74 @@ class ChatListViewModel @Inject constructor(
|
|||||||
chats = filtered,
|
chats = filtered,
|
||||||
archivedChatsCount = stats.first,
|
archivedChatsCount = stats.first,
|
||||||
archivedUnreadCount = stats.second,
|
archivedUnreadCount = stats.second,
|
||||||
|
managementMessage = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun createChatInternal(
|
||||||
|
type: String,
|
||||||
|
title: String,
|
||||||
|
isPublic: Boolean,
|
||||||
|
handle: String?,
|
||||||
|
description: String?,
|
||||||
|
memberIds: List<Long>,
|
||||||
|
successMessage: String,
|
||||||
|
) {
|
||||||
|
val normalizedTitle = title.trim()
|
||||||
|
if (normalizedTitle.isBlank()) {
|
||||||
|
_uiState.update { it.copy(errorMessage = "Title is required.") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (
|
||||||
|
val result = chatRepository.createChat(
|
||||||
|
type = type,
|
||||||
|
title = normalizedTitle,
|
||||||
|
isPublic = isPublic,
|
||||||
|
handle = handle?.trim()?.ifBlank { null },
|
||||||
|
description = description?.trim()?.ifBlank { null },
|
||||||
|
memberIds = memberIds,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
is AppResult.Success -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
managementMessage = successMessage,
|
||||||
|
pendingOpenChatId = result.data.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
refreshCurrentTab(forceRefresh = true)
|
||||||
|
}
|
||||||
|
is AppResult.Error -> _uiState.update {
|
||||||
|
it.copy(errorMessage = result.reason.toUiMessage())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadMembersAndBans(chatId: Long) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isManagementLoading = true, errorMessage = null) }
|
||||||
|
val membersResult = chatRepository.listMembers(chatId = chatId)
|
||||||
|
val bansResult = chatRepository.listBans(chatId = chatId)
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isManagementLoading = false,
|
||||||
|
members = (membersResult as? AppResult.Success)?.data ?: emptyList(),
|
||||||
|
bans = (bansResult as? AppResult.Success)?.data ?: emptyList(),
|
||||||
|
errorMessage = listOf(membersResult, bansResult)
|
||||||
|
.filterIsInstance<AppResult.Error>()
|
||||||
|
.firstOrNull()
|
||||||
|
?.reason
|
||||||
|
?.toUiMessage(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun refreshCurrentTab(forceRefresh: Boolean = false) {
|
private fun refreshCurrentTab(forceRefresh: Boolean = false) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
|
|||||||
@@ -74,13 +74,13 @@
|
|||||||
- [x] Единый global audio focus (1 источник звука)
|
- [x] Единый global audio focus (1 источник звука)
|
||||||
|
|
||||||
## 10. Группы/каналы
|
## 10. Группы/каналы
|
||||||
- [ ] Create group/channel
|
- [x] Create group/channel
|
||||||
- [ ] Join/leave
|
- [x] Join/leave
|
||||||
- [ ] Invite link (create/regenerate/join)
|
- [x] Invite link (create/regenerate/join)
|
||||||
- [x] Roles owner/admin/member
|
- [x] Roles owner/admin/member
|
||||||
- [ ] Admin actions: add/remove/ban/unban/promote/demote
|
- [x] Admin actions: add/remove/ban/unban/promote/demote
|
||||||
- [x] Ограничения канала: писать только owner/admin
|
- [x] Ограничения канала: писать только owner/admin
|
||||||
- [ ] Member visibility rules (скрытие списков/действий)
|
- [x] Member visibility rules (скрытие списков/действий)
|
||||||
|
|
||||||
## 11. Поиск
|
## 11. Поиск
|
||||||
- [ ] Глобальный поиск: users/chats/messages
|
- [ ] Глобальный поиск: users/chats/messages
|
||||||
|
|||||||
Reference in New Issue
Block a user