android: add minimum invite link join flow
Some checks failed
CI / test (push) Has been cancelled

This commit is contained in:
Codex
2026-03-09 12:59:33 +03:00
parent 37396f4da5
commit e91884e14a
14 changed files with 216 additions and 0 deletions

View File

@@ -130,3 +130,9 @@
- Added `ObserveChatUseCase` to expose per-chat permission state to message screen. - 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. - 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. - 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.

View File

@@ -1,8 +1,12 @@
package ru.daemonlord.messenger.data.chat.api package ru.daemonlord.messenger.data.chat.api
import retrofit2.http.Body
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Path import retrofit2.http.Path
import retrofit2.http.POST
import retrofit2.http.Query 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 import ru.daemonlord.messenger.data.chat.dto.ChatReadDto
interface ChatApiService { interface ChatApiService {
@@ -15,4 +19,14 @@ interface ChatApiService {
suspend fun getChatById( suspend fun getChatById(
@Path("chat_id") chatId: Long, @Path("chat_id") chatId: Long,
): ChatReadDto ): 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
} }

View File

@@ -45,3 +45,17 @@ data class ChatReadDto(
@SerialName("created_at") @SerialName("created_at")
val createdAt: String? = null, 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,
)

View File

@@ -1,6 +1,7 @@
package ru.daemonlord.messenger.data.chat.mapper package ru.daemonlord.messenger.data.chat.mapper
import ru.daemonlord.messenger.data.chat.local.model.ChatListLocalModel 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 import ru.daemonlord.messenger.domain.chat.model.ChatItem
fun ChatListLocalModel.toDomain(): ChatItem { fun ChatListLocalModel.toDomain(): ChatItem {
@@ -29,3 +30,30 @@ fun ChatListLocalModel.toDomain(): ChatItem {
updatedSortAt = updatedSortAt, 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,
)
}

View File

@@ -1,8 +1,10 @@
package ru.daemonlord.messenger.data.chat.mapper package ru.daemonlord.messenger.data.chat.mapper
import ru.daemonlord.messenger.data.chat.dto.ChatReadDto 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.ChatEntity
import ru.daemonlord.messenger.data.chat.local.entity.UserShortEntity import ru.daemonlord.messenger.data.chat.local.entity.UserShortEntity
import ru.daemonlord.messenger.domain.chat.model.ChatInviteLink
fun ChatReadDto.toChatEntity(): ChatEntity { fun ChatReadDto.toChatEntity(): ChatEntity {
return ChatEntity( return ChatEntity(
@@ -42,3 +44,11 @@ fun ChatReadDto.toUserShortEntityOrNull(): UserShortEntity? {
avatarUrl = counterpartAvatarUrl, avatarUrl = counterpartAvatarUrl,
) )
} }
fun ChatInviteLinkDto.toDomain(): ChatInviteLink {
return ChatInviteLink(
chatId = chatId,
token = token,
inviteUrl = inviteUrl,
)
}

View File

@@ -10,10 +10,12 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import retrofit2.HttpException import retrofit2.HttpException
import ru.daemonlord.messenger.data.chat.api.ChatApiService 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.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
import ru.daemonlord.messenger.data.chat.mapper.toUserShortEntityOrNull 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.di.IoDispatcher
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.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) { override suspend fun deleteChat(chatId: Long) {
withContext(ioDispatcher) { withContext(ioDispatcher) {
chatDao.deleteChat(chatId = chatId) chatDao.deleteChat(chatId = chatId)

View File

@@ -0,0 +1,7 @@
package ru.daemonlord.messenger.domain.chat.model
data class ChatInviteLink(
val chatId: Long,
val token: String,
val inviteUrl: String,
)

View File

@@ -1,6 +1,7 @@
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.ChatInviteLink
import ru.daemonlord.messenger.domain.chat.model.ChatItem import ru.daemonlord.messenger.domain.chat.model.ChatItem
import ru.daemonlord.messenger.domain.common.AppResult import ru.daemonlord.messenger.domain.common.AppResult
@@ -9,5 +10,7 @@ interface ChatRepository {
fun observeChat(chatId: Long): Flow<ChatItem?> fun observeChat(chatId: Long): Flow<ChatItem?>
suspend fun refreshChats(archived: Boolean): AppResult<Unit> suspend fun refreshChats(archived: Boolean): AppResult<Unit>
suspend fun refreshChat(chatId: Long): 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) suspend fun deleteChat(chatId: Long)
} }

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -12,6 +12,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -21,6 +22,7 @@ import androidx.compose.material3.TabRow
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox 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.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -36,10 +38,17 @@ fun ChatListRoute(
viewModel: ChatListViewModel = hiltViewModel(), viewModel: ChatListViewModel = hiltViewModel(),
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(state.pendingOpenChatId) {
val chatId = state.pendingOpenChatId ?: return@LaunchedEffect
onOpenChat(chatId)
viewModel.onNavigatedToPendingChat()
}
ChatListScreen( ChatListScreen(
state = state, state = state,
onTabSelected = viewModel::onTabSelected, onTabSelected = viewModel::onTabSelected,
onSearchChanged = viewModel::onSearchChanged, onSearchChanged = viewModel::onSearchChanged,
onInviteTokenChanged = viewModel::onInviteTokenChanged,
onJoinByInvite = viewModel::onJoinByInvite,
onRefresh = viewModel::onPullToRefresh, onRefresh = viewModel::onPullToRefresh,
onOpenChat = onOpenChat, onOpenChat = onOpenChat,
) )
@@ -51,6 +60,8 @@ fun ChatListScreen(
state: ChatListUiState, state: ChatListUiState,
onTabSelected: (ChatTab) -> Unit, onTabSelected: (ChatTab) -> Unit,
onSearchChanged: (String) -> Unit, onSearchChanged: (String) -> Unit,
onInviteTokenChanged: (String) -> Unit,
onJoinByInvite: () -> Unit,
onRefresh: () -> Unit, onRefresh: () -> Unit,
onOpenChat: (Long) -> Unit, onOpenChat: (Long) -> Unit,
) { ) {
@@ -82,6 +93,28 @@ fun ChatListScreen(
.padding(horizontal = 16.dp, vertical = 8.dp), .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( PullToRefreshBox(
isRefreshing = state.isRefreshing, isRefreshing = state.isRefreshing,
onRefresh = onRefresh, onRefresh = onRefresh,

View File

@@ -9,4 +9,7 @@ data class ChatListUiState(
val isRefreshing: Boolean = false, val isRefreshing: Boolean = false,
val errorMessage: String? = null, val errorMessage: String? = null,
val chats: List<ChatItem> = emptyList(), val chats: List<ChatItem> = emptyList(),
val inviteTokenInput: String = "",
val isJoiningInvite: Boolean = false,
val pendingOpenChatId: Long? = null,
) )

View File

@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.flatMapLatest
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.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
import ru.daemonlord.messenger.domain.common.AppError import ru.daemonlord.messenger.domain.common.AppError
@@ -23,6 +24,7 @@ import javax.inject.Inject
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 handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase, private val handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase,
) : ViewModel() { ) : ViewModel() {
@@ -52,6 +54,42 @@ class ChatListViewModel @Inject constructor(
refreshCurrentTab(forceRefresh = true) 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() { private fun observeChatStream() {
viewModelScope.launch { viewModelScope.launch {
selectedTab selectedTab

View File

@@ -14,6 +14,8 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import ru.daemonlord.messenger.data.chat.api.ChatApiService 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.dto.ChatReadDto
import ru.daemonlord.messenger.data.chat.local.dao.ChatDao import ru.daemonlord.messenger.data.chat.local.dao.ChatDao
import ru.daemonlord.messenger.data.chat.local.entity.ChatEntity import ru.daemonlord.messenger.data.chat.local.entity.ChatEntity
@@ -109,6 +111,14 @@ class NetworkChatRepositoryTest {
override suspend fun getChatById(chatId: Long): ChatReadDto { override suspend fun getChatById(chatId: Long): ChatReadDto {
return chats.first() 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( private class FakeChatDao(