This commit is contained in:
@@ -130,3 +130,9 @@
|
||||
- Added `ObserveChatUseCase` to expose per-chat permission state to message screen.
|
||||
- Implemented channel send restrictions in `ChatViewModel`: sending/attach disabled for `member` role in `channel` chats.
|
||||
- Added composer-level restriction hint in Chat UI to explain blocked actions.
|
||||
|
||||
### Step 21 - Sprint P0 / 4) Invite join flow (minimum)
|
||||
- Added chat API contracts for invite actions: `POST /api/v1/chats/{chat_id}/invite-link` and `POST /api/v1/chats/join-by-invite`.
|
||||
- Added domain model/use-cases for invite-link creation and join-by-invite.
|
||||
- Extended chat repository with invite operations and local chat upsert on successful join.
|
||||
- Added minimal Chat List UI flow for join-by-invite token input with loading/error handling and auto-open of joined chat.
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package ru.daemonlord.messenger.data.chat.api
|
||||
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Query
|
||||
import ru.daemonlord.messenger.data.chat.dto.ChatInviteLinkDto
|
||||
import ru.daemonlord.messenger.data.chat.dto.ChatJoinByInviteRequestDto
|
||||
import ru.daemonlord.messenger.data.chat.dto.ChatReadDto
|
||||
|
||||
interface ChatApiService {
|
||||
@@ -15,4 +19,14 @@ interface ChatApiService {
|
||||
suspend fun getChatById(
|
||||
@Path("chat_id") chatId: Long,
|
||||
): ChatReadDto
|
||||
|
||||
@POST("/api/v1/chats/{chat_id}/invite-link")
|
||||
suspend fun createInviteLink(
|
||||
@Path("chat_id") chatId: Long,
|
||||
): ChatInviteLinkDto
|
||||
|
||||
@POST("/api/v1/chats/join-by-invite")
|
||||
suspend fun joinByInvite(
|
||||
@Body request: ChatJoinByInviteRequestDto,
|
||||
): ChatReadDto
|
||||
}
|
||||
|
||||
@@ -45,3 +45,17 @@ data class ChatReadDto(
|
||||
@SerialName("created_at")
|
||||
val createdAt: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatInviteLinkDto(
|
||||
@SerialName("chat_id")
|
||||
val chatId: Long,
|
||||
val token: String,
|
||||
@SerialName("invite_url")
|
||||
val inviteUrl: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatJoinByInviteRequestDto(
|
||||
val token: String,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ru.daemonlord.messenger.data.chat.mapper
|
||||
|
||||
import ru.daemonlord.messenger.data.chat.local.model.ChatListLocalModel
|
||||
import ru.daemonlord.messenger.data.chat.local.entity.ChatEntity
|
||||
import ru.daemonlord.messenger.domain.chat.model.ChatItem
|
||||
|
||||
fun ChatListLocalModel.toDomain(): ChatItem {
|
||||
@@ -29,3 +30,30 @@ fun ChatListLocalModel.toDomain(): ChatItem {
|
||||
updatedSortAt = updatedSortAt,
|
||||
)
|
||||
}
|
||||
|
||||
fun ChatEntity.toDomain(): ChatItem {
|
||||
return ChatItem(
|
||||
id = id,
|
||||
publicId = publicId,
|
||||
type = type,
|
||||
title = title,
|
||||
displayTitle = displayTitle,
|
||||
handle = handle,
|
||||
avatarUrl = avatarUrl,
|
||||
archived = archived,
|
||||
pinned = pinned,
|
||||
muted = muted,
|
||||
unreadCount = unreadCount,
|
||||
unreadMentionsCount = unreadMentionsCount,
|
||||
counterpartUsername = counterpartUsername,
|
||||
counterpartName = counterpartName,
|
||||
counterpartAvatarUrl = counterpartAvatarUrl,
|
||||
counterpartIsOnline = counterpartIsOnline,
|
||||
counterpartLastSeenAt = counterpartLastSeenAt,
|
||||
lastMessageText = lastMessageText,
|
||||
lastMessageType = lastMessageType,
|
||||
lastMessageCreatedAt = lastMessageCreatedAt,
|
||||
myRole = myRole,
|
||||
updatedSortAt = updatedSortAt,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package ru.daemonlord.messenger.data.chat.mapper
|
||||
|
||||
import ru.daemonlord.messenger.data.chat.dto.ChatReadDto
|
||||
import ru.daemonlord.messenger.data.chat.dto.ChatInviteLinkDto
|
||||
import ru.daemonlord.messenger.data.chat.local.entity.ChatEntity
|
||||
import ru.daemonlord.messenger.data.chat.local.entity.UserShortEntity
|
||||
import ru.daemonlord.messenger.domain.chat.model.ChatInviteLink
|
||||
|
||||
fun ChatReadDto.toChatEntity(): ChatEntity {
|
||||
return ChatEntity(
|
||||
@@ -42,3 +44,11 @@ fun ChatReadDto.toUserShortEntityOrNull(): UserShortEntity? {
|
||||
avatarUrl = counterpartAvatarUrl,
|
||||
)
|
||||
}
|
||||
|
||||
fun ChatInviteLinkDto.toDomain(): ChatInviteLink {
|
||||
return ChatInviteLink(
|
||||
chatId = chatId,
|
||||
token = token,
|
||||
inviteUrl = inviteUrl,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,10 +10,12 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import retrofit2.HttpException
|
||||
import ru.daemonlord.messenger.data.chat.api.ChatApiService
|
||||
import ru.daemonlord.messenger.data.chat.dto.ChatJoinByInviteRequestDto
|
||||
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
|
||||
import ru.daemonlord.messenger.data.chat.mapper.toUserShortEntityOrNull
|
||||
import ru.daemonlord.messenger.domain.chat.model.ChatInviteLink
|
||||
import ru.daemonlord.messenger.di.IoDispatcher
|
||||
import ru.daemonlord.messenger.domain.chat.model.ChatItem
|
||||
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
|
||||
@@ -75,6 +77,26 @@ class NetworkChatRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun createInviteLink(chatId: Long): AppResult<ChatInviteLink> = withContext(ioDispatcher) {
|
||||
try {
|
||||
AppResult.Success(chatApiService.createInviteLink(chatId = chatId).toDomain())
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun joinByInvite(token: String): AppResult<ChatItem> = withContext(ioDispatcher) {
|
||||
try {
|
||||
val joined = chatApiService.joinByInvite(request = ChatJoinByInviteRequestDto(token = token))
|
||||
chatDao.upsertUsers(joined.toUserShortEntityOrNull()?.let(::listOf).orEmpty())
|
||||
val chatEntity = joined.toChatEntity()
|
||||
chatDao.upsertChats(listOf(chatEntity))
|
||||
AppResult.Success(chatEntity.toDomain())
|
||||
} catch (error: Throwable) {
|
||||
AppResult.Error(error.toAppError())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun deleteChat(chatId: Long) {
|
||||
withContext(ioDispatcher) {
|
||||
chatDao.deleteChat(chatId = chatId)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package ru.daemonlord.messenger.domain.chat.model
|
||||
|
||||
data class ChatInviteLink(
|
||||
val chatId: Long,
|
||||
val token: String,
|
||||
val inviteUrl: String,
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
package ru.daemonlord.messenger.domain.chat.repository
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import ru.daemonlord.messenger.domain.chat.model.ChatInviteLink
|
||||
import ru.daemonlord.messenger.domain.chat.model.ChatItem
|
||||
import ru.daemonlord.messenger.domain.common.AppResult
|
||||
|
||||
@@ -9,5 +10,7 @@ interface ChatRepository {
|
||||
fun observeChat(chatId: Long): Flow<ChatItem?>
|
||||
suspend fun refreshChats(archived: Boolean): AppResult<Unit>
|
||||
suspend fun refreshChat(chatId: Long): AppResult<Unit>
|
||||
suspend fun createInviteLink(chatId: Long): AppResult<ChatInviteLink>
|
||||
suspend fun joinByInvite(token: String): AppResult<ChatItem>
|
||||
suspend fun deleteChat(chatId: Long)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package ru.daemonlord.messenger.domain.chat.usecase
|
||||
|
||||
import ru.daemonlord.messenger.domain.chat.model.ChatInviteLink
|
||||
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
|
||||
import ru.daemonlord.messenger.domain.common.AppResult
|
||||
import javax.inject.Inject
|
||||
|
||||
class CreateInviteLinkUseCase @Inject constructor(
|
||||
private val repository: ChatRepository,
|
||||
) {
|
||||
suspend operator fun invoke(chatId: Long): AppResult<ChatInviteLink> {
|
||||
return repository.createInviteLink(chatId = chatId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package ru.daemonlord.messenger.domain.chat.usecase
|
||||
|
||||
import ru.daemonlord.messenger.domain.chat.model.ChatItem
|
||||
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
|
||||
import ru.daemonlord.messenger.domain.common.AppResult
|
||||
import javax.inject.Inject
|
||||
|
||||
class JoinByInviteUseCase @Inject constructor(
|
||||
private val repository: ChatRepository,
|
||||
) {
|
||||
suspend operator fun invoke(token: String): AppResult<ChatItem> {
|
||||
return repository.joinByInvite(token = token)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.AssistChipDefaults
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -21,6 +22,7 @@ import androidx.compose.material3.TabRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -36,10 +38,17 @@ fun ChatListRoute(
|
||||
viewModel: ChatListViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
LaunchedEffect(state.pendingOpenChatId) {
|
||||
val chatId = state.pendingOpenChatId ?: return@LaunchedEffect
|
||||
onOpenChat(chatId)
|
||||
viewModel.onNavigatedToPendingChat()
|
||||
}
|
||||
ChatListScreen(
|
||||
state = state,
|
||||
onTabSelected = viewModel::onTabSelected,
|
||||
onSearchChanged = viewModel::onSearchChanged,
|
||||
onInviteTokenChanged = viewModel::onInviteTokenChanged,
|
||||
onJoinByInvite = viewModel::onJoinByInvite,
|
||||
onRefresh = viewModel::onPullToRefresh,
|
||||
onOpenChat = onOpenChat,
|
||||
)
|
||||
@@ -51,6 +60,8 @@ fun ChatListScreen(
|
||||
state: ChatListUiState,
|
||||
onTabSelected: (ChatTab) -> Unit,
|
||||
onSearchChanged: (String) -> Unit,
|
||||
onInviteTokenChanged: (String) -> Unit,
|
||||
onJoinByInvite: () -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
onOpenChat: (Long) -> Unit,
|
||||
) {
|
||||
@@ -82,6 +93,28 @@ fun ChatListScreen(
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = state.inviteTokenInput,
|
||||
onValueChange = onInviteTokenChanged,
|
||||
label = { Text("Invite token") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Button(
|
||||
onClick = onJoinByInvite,
|
||||
enabled = !state.isJoiningInvite && state.inviteTokenInput.isNotBlank(),
|
||||
) {
|
||||
Text(if (state.isJoiningInvite) "..." else "Join")
|
||||
}
|
||||
}
|
||||
|
||||
PullToRefreshBox(
|
||||
isRefreshing = state.isRefreshing,
|
||||
onRefresh = onRefresh,
|
||||
|
||||
@@ -9,4 +9,7 @@ data class ChatListUiState(
|
||||
val isRefreshing: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
val chats: List<ChatItem> = emptyList(),
|
||||
val inviteTokenInput: String = "",
|
||||
val isJoiningInvite: Boolean = false,
|
||||
val pendingOpenChatId: Long? = null,
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.daemonlord.messenger.domain.chat.model.ChatItem
|
||||
import ru.daemonlord.messenger.domain.chat.usecase.JoinByInviteUseCase
|
||||
import ru.daemonlord.messenger.domain.chat.usecase.ObserveChatsUseCase
|
||||
import ru.daemonlord.messenger.domain.chat.usecase.RefreshChatsUseCase
|
||||
import ru.daemonlord.messenger.domain.common.AppError
|
||||
@@ -23,6 +24,7 @@ import javax.inject.Inject
|
||||
class ChatListViewModel @Inject constructor(
|
||||
private val observeChatsUseCase: ObserveChatsUseCase,
|
||||
private val refreshChatsUseCase: RefreshChatsUseCase,
|
||||
private val joinByInviteUseCase: JoinByInviteUseCase,
|
||||
private val handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -52,6 +54,42 @@ class ChatListViewModel @Inject constructor(
|
||||
refreshCurrentTab(forceRefresh = true)
|
||||
}
|
||||
|
||||
fun onInviteTokenChanged(value: String) {
|
||||
_uiState.update { it.copy(inviteTokenInput = value) }
|
||||
}
|
||||
|
||||
fun onJoinByInvite() {
|
||||
val token = uiState.value.inviteTokenInput.trim()
|
||||
if (token.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isJoiningInvite = true, errorMessage = null) }
|
||||
when (val result = joinByInviteUseCase(token = token)) {
|
||||
is AppResult.Success -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isJoiningInvite = false,
|
||||
inviteTokenInput = "",
|
||||
pendingOpenChatId = result.data.id,
|
||||
)
|
||||
}
|
||||
refreshCurrentTab(forceRefresh = true)
|
||||
}
|
||||
is AppResult.Error -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isJoiningInvite = false,
|
||||
errorMessage = result.reason.toUiMessage(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onNavigatedToPendingChat() {
|
||||
_uiState.update { it.copy(pendingOpenChatId = null) }
|
||||
}
|
||||
|
||||
private fun observeChatStream() {
|
||||
viewModelScope.launch {
|
||||
selectedTab
|
||||
|
||||
@@ -14,6 +14,8 @@ import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import ru.daemonlord.messenger.data.chat.api.ChatApiService
|
||||
import ru.daemonlord.messenger.data.chat.dto.ChatInviteLinkDto
|
||||
import ru.daemonlord.messenger.data.chat.dto.ChatJoinByInviteRequestDto
|
||||
import ru.daemonlord.messenger.data.chat.dto.ChatReadDto
|
||||
import ru.daemonlord.messenger.data.chat.local.dao.ChatDao
|
||||
import ru.daemonlord.messenger.data.chat.local.entity.ChatEntity
|
||||
@@ -109,6 +111,14 @@ class NetworkChatRepositoryTest {
|
||||
override suspend fun getChatById(chatId: Long): ChatReadDto {
|
||||
return chats.first()
|
||||
}
|
||||
|
||||
override suspend fun createInviteLink(chatId: Long): ChatInviteLinkDto {
|
||||
return ChatInviteLinkDto(chatId = chatId, token = "token", inviteUrl = "https://chat.daemonlord.ru/invite/token")
|
||||
}
|
||||
|
||||
override suspend fun joinByInvite(request: ChatJoinByInviteRequestDto): ChatReadDto {
|
||||
return chats.first()
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeChatDao(
|
||||
|
||||
Reference in New Issue
Block a user