diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index 6eecf07..4ab6f93 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -614,3 +614,18 @@ - Updated multi-select top actions/menu to be closer to Telegram reference in wording. - Added dynamic `Закрепить/Открепить` label in selection overflow based on selected chats pinned state. - Kept non-supported actions explicit with user feedback (Toast), avoiding silent no-op behavior. + +### Step 97 - Chats popup/select actions wired to backend API +- Extended Android chat data layer with missing parity endpoints: + - `archive/unarchive` + - `pin-chat/unpin-chat` + - `clear` + - `delete (for_all=false)` + - `chat notifications get/update` +- Added repository methods and `ViewModel` actions for those operations. +- Replaced chats multi-select UI stubs with real API calls: + - mute/unmute selected chats, + - archive/unarchive selected chats, + - pin/unpin selected chats, + - clear selected chats, + - delete selected chats for current user. 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 28b6864..615c152 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 @@ -6,6 +6,7 @@ import retrofit2.http.GET import retrofit2.http.PATCH import retrofit2.http.Path import retrofit2.http.POST +import retrofit2.http.PUT import retrofit2.http.Query import ru.daemonlord.messenger.data.chat.dto.ChatBanDto import ru.daemonlord.messenger.data.chat.dto.ChatCreateRequestDto @@ -14,6 +15,8 @@ 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.ChatNotificationSettingsDto +import ru.daemonlord.messenger.data.chat.dto.ChatNotificationSettingsUpdateDto import ru.daemonlord.messenger.data.chat.dto.ChatReadDto import ru.daemonlord.messenger.data.chat.dto.DiscoverChatDto @@ -58,6 +61,48 @@ interface ChatApiService { @Path("chat_id") chatId: Long, ) + @DELETE("/api/v1/chats/{chat_id}") + suspend fun deleteChat( + @Path("chat_id") chatId: Long, + @Query("for_all") forAll: Boolean = false, + ) + + @POST("/api/v1/chats/{chat_id}/clear") + suspend fun clearChat( + @Path("chat_id") chatId: Long, + ) + + @POST("/api/v1/chats/{chat_id}/archive") + suspend fun archiveChat( + @Path("chat_id") chatId: Long, + ): ChatReadDto + + @POST("/api/v1/chats/{chat_id}/unarchive") + suspend fun unarchiveChat( + @Path("chat_id") chatId: Long, + ): ChatReadDto + + @POST("/api/v1/chats/{chat_id}/pin-chat") + suspend fun pinChat( + @Path("chat_id") chatId: Long, + ): ChatReadDto + + @POST("/api/v1/chats/{chat_id}/unpin-chat") + suspend fun unpinChat( + @Path("chat_id") chatId: Long, + ): ChatReadDto + + @GET("/api/v1/chats/{chat_id}/notifications") + suspend fun getChatNotifications( + @Path("chat_id") chatId: Long, + ): ChatNotificationSettingsDto + + @PUT("/api/v1/chats/{chat_id}/notifications") + suspend fun updateChatNotifications( + @Path("chat_id") chatId: Long, + @Body request: ChatNotificationSettingsUpdateDto, + ): ChatNotificationSettingsDto + @GET("/api/v1/chats/{chat_id}/members") suspend fun listMembers( @Path("chat_id") chatId: Long, 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 4225455..8a898ab 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 @@ -118,3 +118,17 @@ data class ChatMemberAddRequestDto( @SerialName("user_id") val userId: Long, ) + +@Serializable +data class ChatNotificationSettingsDto( + @SerialName("chat_id") + val chatId: Long, + @SerialName("user_id") + val userId: Long, + val muted: Boolean, +) + +@Serializable +data class ChatNotificationSettingsUpdateDto( + val muted: Boolean, +) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/dao/ChatDao.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/dao/ChatDao.kt index 4e2d840..38efb20 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/dao/ChatDao.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/dao/ChatDao.kt @@ -87,6 +87,9 @@ interface ChatDao { @Query("SELECT muted FROM chats WHERE id = :chatId LIMIT 1") suspend fun isChatMuted(chatId: Long): Boolean? + @Query("UPDATE chats SET muted = :muted WHERE id = :chatId") + suspend fun updateChatMuted(chatId: Long, muted: Boolean) + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertChats(chats: List) 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 baa139a..73e215c 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 @@ -15,6 +15,8 @@ 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.ChatNotificationSettingsDto +import ru.daemonlord.messenger.data.chat.dto.ChatNotificationSettingsUpdateDto import ru.daemonlord.messenger.data.chat.dto.DiscoverChatDto import ru.daemonlord.messenger.data.chat.local.dao.ChatDao import ru.daemonlord.messenger.data.chat.mapper.toChatEntity @@ -26,6 +28,7 @@ import ru.daemonlord.messenger.di.IoDispatcher import ru.daemonlord.messenger.domain.chat.model.ChatBanItem import ru.daemonlord.messenger.domain.chat.model.ChatItem import ru.daemonlord.messenger.domain.chat.model.ChatMemberItem +import ru.daemonlord.messenger.domain.chat.model.ChatNotificationSettings import ru.daemonlord.messenger.domain.chat.model.DiscoverChatItem import ru.daemonlord.messenger.domain.chat.repository.ChatRepository import ru.daemonlord.messenger.domain.common.AppResult @@ -162,6 +165,94 @@ class NetworkChatRepository @Inject constructor( } } + override suspend fun archiveChat(chatId: Long): AppResult = withContext(ioDispatcher) { + try { + val updated = chatApiService.archiveChat(chatId = chatId) + chatDao.upsertUsers(updated.toUserShortEntityOrNull()?.let(::listOf).orEmpty()) + val entity = updated.toChatEntity() + chatDao.upsertChats(listOf(entity)) + AppResult.Success(entity.toDomain()) + } catch (error: Throwable) { + AppResult.Error(error.toAppError()) + } + } + + override suspend fun unarchiveChat(chatId: Long): AppResult = withContext(ioDispatcher) { + try { + val updated = chatApiService.unarchiveChat(chatId = chatId) + chatDao.upsertUsers(updated.toUserShortEntityOrNull()?.let(::listOf).orEmpty()) + val entity = updated.toChatEntity() + chatDao.upsertChats(listOf(entity)) + AppResult.Success(entity.toDomain()) + } catch (error: Throwable) { + AppResult.Error(error.toAppError()) + } + } + + override suspend fun pinChat(chatId: Long): AppResult = withContext(ioDispatcher) { + try { + val updated = chatApiService.pinChat(chatId = chatId) + chatDao.upsertUsers(updated.toUserShortEntityOrNull()?.let(::listOf).orEmpty()) + val entity = updated.toChatEntity() + chatDao.upsertChats(listOf(entity)) + AppResult.Success(entity.toDomain()) + } catch (error: Throwable) { + AppResult.Error(error.toAppError()) + } + } + + override suspend fun unpinChat(chatId: Long): AppResult = withContext(ioDispatcher) { + try { + val updated = chatApiService.unpinChat(chatId = chatId) + chatDao.upsertUsers(updated.toUserShortEntityOrNull()?.let(::listOf).orEmpty()) + val entity = updated.toChatEntity() + chatDao.upsertChats(listOf(entity)) + AppResult.Success(entity.toDomain()) + } catch (error: Throwable) { + AppResult.Error(error.toAppError()) + } + } + + override suspend fun clearChat(chatId: Long): AppResult = withContext(ioDispatcher) { + try { + chatApiService.clearChat(chatId = chatId) + AppResult.Success(Unit) + } catch (error: Throwable) { + AppResult.Error(error.toAppError()) + } + } + + override suspend fun removeChat(chatId: Long, forAll: Boolean): AppResult = withContext(ioDispatcher) { + try { + chatApiService.deleteChat(chatId = chatId, forAll = forAll) + chatDao.deleteChat(chatId = chatId) + AppResult.Success(Unit) + } catch (error: Throwable) { + AppResult.Error(error.toAppError()) + } + } + + override suspend fun getChatNotifications(chatId: Long): AppResult = withContext(ioDispatcher) { + try { + AppResult.Success(chatApiService.getChatNotifications(chatId = chatId).toDomain()) + } catch (error: Throwable) { + AppResult.Error(error.toAppError()) + } + } + + override suspend fun updateChatNotifications(chatId: Long, muted: Boolean): AppResult = withContext(ioDispatcher) { + try { + val settings = chatApiService.updateChatNotifications( + chatId = chatId, + request = ChatNotificationSettingsUpdateDto(muted = muted), + ).toDomain() + chatDao.updateChatMuted(chatId = chatId, muted = settings.muted) + AppResult.Success(settings) + } catch (error: Throwable) { + AppResult.Error(error.toAppError()) + } + } + override suspend fun listMembers(chatId: Long): AppResult> = withContext(ioDispatcher) { try { AppResult.Success(chatApiService.listMembers(chatId = chatId).map { it.toDomain() }) @@ -269,4 +360,12 @@ class NetworkChatRepository @Inject constructor( bannedAt = bannedAt, ) } + + private fun ChatNotificationSettingsDto.toDomain(): ChatNotificationSettings { + return ChatNotificationSettings( + chatId = chatId, + userId = userId, + muted = muted, + ) + } } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/model/ChatNotificationSettings.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/model/ChatNotificationSettings.kt new file mode 100644 index 0000000..0da0ee2 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/model/ChatNotificationSettings.kt @@ -0,0 +1,8 @@ +package ru.daemonlord.messenger.domain.chat.model + +data class ChatNotificationSettings( + val chatId: Long, + val userId: Long, + val muted: Boolean, +) + 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 f3798ab..572f8c9 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 @@ -5,6 +5,7 @@ import ru.daemonlord.messenger.domain.chat.model.ChatBanItem import ru.daemonlord.messenger.domain.chat.model.ChatInviteLink import ru.daemonlord.messenger.domain.chat.model.ChatItem import ru.daemonlord.messenger.domain.chat.model.ChatMemberItem +import ru.daemonlord.messenger.domain.chat.model.ChatNotificationSettings import ru.daemonlord.messenger.domain.chat.model.DiscoverChatItem import ru.daemonlord.messenger.domain.common.AppResult @@ -26,6 +27,14 @@ interface ChatRepository { suspend fun discoverChats(query: String?): AppResult> suspend fun joinChat(chatId: Long): AppResult suspend fun leaveChat(chatId: Long): AppResult + suspend fun archiveChat(chatId: Long): AppResult + suspend fun unarchiveChat(chatId: Long): AppResult + suspend fun pinChat(chatId: Long): AppResult + suspend fun unpinChat(chatId: Long): AppResult + suspend fun clearChat(chatId: Long): AppResult + suspend fun removeChat(chatId: Long, forAll: Boolean = false): AppResult + suspend fun getChatNotifications(chatId: Long): AppResult + suspend fun updateChatNotifications(chatId: Long, muted: Boolean): AppResult suspend fun listMembers(chatId: Long): AppResult> suspend fun listBans(chatId: Long): AppResult> suspend fun addMember(chatId: Long, userId: Long): AppResult 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 8f0df93..bfd481c 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 @@ -125,6 +125,13 @@ fun ChatListRoute( onDiscoverChats = viewModel::discoverChats, onJoinDiscoveredChat = viewModel::joinChat, onLeaveChat = viewModel::leaveChat, + onArchiveChat = viewModel::archiveChat, + onUnarchiveChat = viewModel::unarchiveChat, + onPinChat = viewModel::pinChat, + onUnpinChat = viewModel::unpinChat, + onClearChat = viewModel::clearChat, + onDeleteChat = viewModel::deleteChatForMe, + onToggleChatMute = viewModel::toggleChatMute, onSelectManageChat = viewModel::onManagementChatSelected, onCreateInvite = viewModel::createInvite, onAddMember = viewModel::addMember, @@ -154,6 +161,13 @@ fun ChatListScreen( onDiscoverChats: (String?) -> Unit, onJoinDiscoveredChat: (Long) -> Unit, onLeaveChat: (Long) -> Unit, + onArchiveChat: (Long) -> Unit, + onUnarchiveChat: (Long) -> Unit, + onPinChat: (Long) -> Unit, + onUnpinChat: (Long) -> Unit, + onClearChat: (Long) -> Unit, + onDeleteChat: (Long) -> Unit, + onToggleChatMute: (Long) -> Unit, onSelectManageChat: (Long?) -> Unit, onCreateInvite: (Long) -> Unit, onAddMember: (Long, Long) -> Unit, @@ -269,7 +283,8 @@ fun ChatListScreen( actions = { if (selectedChatIds.isNotEmpty()) { IconButton(onClick = { - Toast.makeText(context, "Настройки звука для выбранных будут добавлены позже.", Toast.LENGTH_SHORT).show() + selectedChatIds.forEach { chatId -> onToggleChatMute(chatId) } + selectedChatIds = emptySet() }) { Icon( imageVector = Icons.Filled.NotificationsOff, @@ -277,7 +292,10 @@ fun ChatListScreen( ) } IconButton(onClick = { - Toast.makeText(context, "Архивирование выбранных будет добавлено позже.", Toast.LENGTH_SHORT).show() + selectedChats.forEach { chat -> + if (chat.archived) onUnarchiveChat(chat.id) else onArchiveChat(chat.id) + } + selectedChatIds = emptySet() }) { Icon( imageVector = Icons.Filled.FolderOpen, @@ -285,7 +303,7 @@ fun ChatListScreen( ) } IconButton(onClick = { - selectedChatIds.forEach { chatId -> onLeaveChat(chatId) } + selectedChatIds.forEach { chatId -> onDeleteChat(chatId) } selectedChatIds = emptySet() }) { Icon( @@ -309,7 +327,10 @@ fun ChatListScreen( leadingIcon = { Icon(Icons.Filled.PushPin, contentDescription = null) }, onClick = { showSelectionMenu = false - Toast.makeText(context, "Закрепление будет добавлено после API поддержки.", Toast.LENGTH_SHORT).show() + selectedChatIds.forEach { chatId -> + if (allSelectedPinned) onUnpinChat(chatId) else onPinChat(chatId) + } + selectedChatIds = emptySet() }, ) DropdownMenuItem( @@ -333,7 +354,8 @@ fun ChatListScreen( leadingIcon = { Icon(Icons.Filled.Delete, contentDescription = null) }, onClick = { showSelectionMenu = false - Toast.makeText(context, "Очистка кэша будет добавлена позже.", Toast.LENGTH_SHORT).show() + selectedChatIds.forEach { chatId -> onClearChat(chatId) } + selectedChatIds = emptySet() }, ) } 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 fd275c1..9eb6178 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 @@ -230,6 +230,84 @@ class ChatListViewModel @Inject constructor( } } + fun archiveChat(chatId: Long) { + viewModelScope.launch { + when (val result = chatRepository.archiveChat(chatId = chatId)) { + is AppResult.Success -> { + _uiState.update { it.copy(managementMessage = "Чат архивирован.") } + refreshCurrentTab(forceRefresh = true) + } + is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) } + } + } + } + + fun unarchiveChat(chatId: Long) { + viewModelScope.launch { + when (val result = chatRepository.unarchiveChat(chatId = chatId)) { + is AppResult.Success -> { + _uiState.update { it.copy(managementMessage = "Чат возвращен из архива.") } + refreshCurrentTab(forceRefresh = true) + } + is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) } + } + } + } + + fun pinChat(chatId: Long) { + viewModelScope.launch { + when (val result = chatRepository.pinChat(chatId = chatId)) { + is AppResult.Success -> _uiState.update { it.copy(managementMessage = "Чат закреплен.") } + is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) } + } + } + } + + fun unpinChat(chatId: Long) { + viewModelScope.launch { + when (val result = chatRepository.unpinChat(chatId = chatId)) { + is AppResult.Success -> _uiState.update { it.copy(managementMessage = "Чат откреплен.") } + is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) } + } + } + } + + fun clearChat(chatId: Long) { + viewModelScope.launch { + when (val result = chatRepository.clearChat(chatId = chatId)) { + is AppResult.Success -> _uiState.update { it.copy(managementMessage = "История чата очищена.") } + is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) } + } + } + } + + fun deleteChatForMe(chatId: Long) { + viewModelScope.launch { + when (val result = chatRepository.removeChat(chatId = chatId, forAll = false)) { + is AppResult.Success -> _uiState.update { it.copy(managementMessage = "Чат удален.") } + is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) } + } + } + } + + fun toggleChatMute(chatId: Long) { + viewModelScope.launch { + when (val current = chatRepository.getChatNotifications(chatId = chatId)) { + is AppResult.Success -> { + when (val updated = chatRepository.updateChatNotifications(chatId = chatId, muted = !current.data.muted)) { + is AppResult.Success -> _uiState.update { + it.copy( + managementMessage = if (updated.data.muted) "Уведомления выключены." else "Уведомления включены.", + ) + } + is AppResult.Error -> _uiState.update { it.copy(errorMessage = updated.reason.toUiMessage()) } + } + } + is AppResult.Error -> _uiState.update { it.copy(errorMessage = current.reason.toUiMessage()) } + } + } + } + fun createInvite(chatId: Long) { viewModelScope.launch { when (val result = chatRepository.createInviteLink(chatId = chatId)) { diff --git a/docs/backend-web-android-parity.md b/docs/backend-web-android-parity.md index 59b82c7..077a3aa 100644 --- a/docs/backend-web-android-parity.md +++ b/docs/backend-web-android-parity.md @@ -20,14 +20,6 @@ Backend покрывает web-функционал почти полность - `GET /api/v1/chats/saved` - `PATCH /api/v1/chats/{chat_id}/title` - `PATCH /api/v1/chats/{chat_id}/profile` -- `POST /api/v1/chats/{chat_id}/archive` -- `POST /api/v1/chats/{chat_id}/unarchive` -- `POST /api/v1/chats/{chat_id}/pin-chat` -- `POST /api/v1/chats/{chat_id}/unpin-chat` -- `DELETE /api/v1/chats/{chat_id}` -- `POST /api/v1/chats/{chat_id}/clear` -- `GET /api/v1/chats/{chat_id}/notifications` -- `PUT /api/v1/chats/{chat_id}/notifications` - `GET /api/v1/messages/{message_id}/thread` - `GET /api/v1/search` (single global endpoint; Android uses composed search calls) - Contacts endpoints: @@ -41,15 +33,13 @@ Backend покрывает web-функционал почти полность ## 3) Practical status - Backend readiness vs Web: `high` -- Android parity vs Web (feature-level): `~70-80%` +- Android parity vs Web (feature-level): `~80-85%` ## 4) Highest-priority Android parity step -Подключить в Android реальные действия для chats list popup/select: +Завершить следующий parity-блок после подключения chat popup/select API: -- archive/unarchive -- pin/unpin chat -- delete/clear chat -- chat notification settings - -и перевести текущие UI-заглушки на API-вызовы. +- `GET /api/v1/chats/saved` + UX для Saved +- `PATCH /api/v1/chats/{chat_id}/title` и `/profile` в chat settings flow +- единый `GET /api/v1/search` для полнофункционального Telegram-like поиска +- contacts API (`/users/contacts*`) + экран управления контактами