From e91884e14a3876a0a8a5820aa4709ba1c34579a9 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 9 Mar 2026 12:59:33 +0300 Subject: [PATCH] android: add minimum invite link join flow --- android/CHANGELOG.md | 6 +++ .../messenger/data/chat/api/ChatApiService.kt | 14 +++++++ .../messenger/data/chat/dto/ChatDtos.kt | 14 +++++++ .../data/chat/mapper/ChatLocalMapper.kt | 28 ++++++++++++++ .../data/chat/mapper/ChatRemoteMapper.kt | 10 +++++ .../chat/repository/NetworkChatRepository.kt | 22 +++++++++++ .../domain/chat/model/ChatInviteLink.kt | 7 ++++ .../domain/chat/repository/ChatRepository.kt | 3 ++ .../chat/usecase/CreateInviteLinkUseCase.kt | 14 +++++++ .../chat/usecase/JoinByInviteUseCase.kt | 14 +++++++ .../messenger/ui/chats/ChatListScreen.kt | 33 ++++++++++++++++ .../messenger/ui/chats/ChatListUiState.kt | 3 ++ .../messenger/ui/chats/ChatListViewModel.kt | 38 +++++++++++++++++++ .../repository/NetworkChatRepositoryTest.kt | 10 +++++ 14 files changed, 216 insertions(+) create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/domain/chat/model/ChatInviteLink.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/domain/chat/usecase/CreateInviteLinkUseCase.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/domain/chat/usecase/JoinByInviteUseCase.kt diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index dfd6792..6559b3c 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -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. diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/api/ChatApiService.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/api/ChatApiService.kt index 0abe398..35a4b20 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/api/ChatApiService.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/api/ChatApiService.kt @@ -1,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 } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/dto/ChatDtos.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/dto/ChatDtos.kt index b5529d6..f752b8d 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/dto/ChatDtos.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/dto/ChatDtos.kt @@ -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, +) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/mapper/ChatLocalMapper.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/mapper/ChatLocalMapper.kt index b7bd5fd..0503734 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/mapper/ChatLocalMapper.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/mapper/ChatLocalMapper.kt @@ -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, + ) +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/mapper/ChatRemoteMapper.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/mapper/ChatRemoteMapper.kt index f4d9dbd..2d9b20b 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/mapper/ChatRemoteMapper.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/mapper/ChatRemoteMapper.kt @@ -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, + ) +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/repository/NetworkChatRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/repository/NetworkChatRepository.kt index 9ffa972..cec550a 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/repository/NetworkChatRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/repository/NetworkChatRepository.kt @@ -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 = withContext(ioDispatcher) { + try { + AppResult.Success(chatApiService.createInviteLink(chatId = chatId).toDomain()) + } catch (error: Throwable) { + AppResult.Error(error.toAppError()) + } + } + + override suspend fun joinByInvite(token: String): AppResult = 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) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/model/ChatInviteLink.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/model/ChatInviteLink.kt new file mode 100644 index 0000000..d72914f --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/model/ChatInviteLink.kt @@ -0,0 +1,7 @@ +package ru.daemonlord.messenger.domain.chat.model + +data class ChatInviteLink( + val chatId: Long, + val token: String, + val inviteUrl: String, +) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/repository/ChatRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/repository/ChatRepository.kt index 83dcca9..2e53eb9 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/repository/ChatRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/repository/ChatRepository.kt @@ -1,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 suspend fun refreshChats(archived: Boolean): AppResult suspend fun refreshChat(chatId: Long): AppResult + suspend fun createInviteLink(chatId: Long): AppResult + suspend fun joinByInvite(token: String): AppResult suspend fun deleteChat(chatId: Long) } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/usecase/CreateInviteLinkUseCase.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/usecase/CreateInviteLinkUseCase.kt new file mode 100644 index 0000000..d054150 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/usecase/CreateInviteLinkUseCase.kt @@ -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 { + return repository.createInviteLink(chatId = chatId) + } +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/usecase/JoinByInviteUseCase.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/usecase/JoinByInviteUseCase.kt new file mode 100644 index 0000000..6a38029 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/usecase/JoinByInviteUseCase.kt @@ -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 { + return repository.joinByInvite(token = token) + } +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt index ee19c4f..fb06b01 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt @@ -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, diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListUiState.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListUiState.kt index 373e8da..eb1e71d 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListUiState.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListUiState.kt @@ -9,4 +9,7 @@ data class ChatListUiState( val isRefreshing: Boolean = false, val errorMessage: String? = null, val chats: List = emptyList(), + val inviteTokenInput: String = "", + val isJoiningInvite: Boolean = false, + val pendingOpenChatId: Long? = null, ) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListViewModel.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListViewModel.kt index d501ad2..1787073 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListViewModel.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListViewModel.kt @@ -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 diff --git a/android/app/src/test/java/ru/daemonlord/messenger/data/chat/repository/NetworkChatRepositoryTest.kt b/android/app/src/test/java/ru/daemonlord/messenger/data/chat/repository/NetworkChatRepositoryTest.kt index f23fbec..84257fc 100644 --- a/android/app/src/test/java/ru/daemonlord/messenger/data/chat/repository/NetworkChatRepositoryTest.kt +++ b/android/app/src/test/java/ru/daemonlord/messenger/data/chat/repository/NetworkChatRepositoryTest.kt @@ -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(