Skip to content

Commit

Permalink
feat: remove conversation from folder [WPB-14630] (#3230)
Browse files Browse the repository at this point in the history
* feat: remove conversation from folder

* remove pragma key off

* drop instead of move labeled conversations
  • Loading branch information
Garzas authored Jan 15, 2025
1 parent 2a6796a commit b304f30
Show file tree
Hide file tree
Showing 11 changed files with 387 additions and 1 deletion.
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ internal interface ConversationFolderRepository {
suspend fun fetchConversationFolders(): Either<CoreFailure, Unit>
suspend fun addConversationToFolder(conversationId: QualifiedID, folderId: String): Either<CoreFailure, Unit>
suspend fun removeConversationFromFolder(conversationId: QualifiedID, folderId: String): Either<CoreFailure, Unit>
suspend fun removeFolder(folderId: String): Either<CoreFailure, Unit>
suspend fun syncConversationFoldersFromLocal(): Either<CoreFailure, Unit>
suspend fun observeFolders(): Flow<Either<CoreFailure, List<ConversationFolder>>>
}
Expand Down Expand Up @@ -143,6 +144,10 @@ internal class ConversationFolderDataSource internal constructor(
}
}

override suspend fun removeFolder(folderId: String): Either<CoreFailure, Unit> = wrapStorageRequest {
conversationFolderDAO.removeFolder(folderId)
}

override suspend fun syncConversationFoldersFromLocal(): Either<CoreFailure, Unit> {
kaliumLogger.withFeatureId(CONVERSATIONS_FOLDERS).v("Syncing conversation folders from local")
return wrapStorageRequest { conversationFolderDAO.getFoldersWithConversations().map { it.toModel() } }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ import com.wire.kalium.logic.feature.conversation.folder.ObserveUserFoldersUseCa
import com.wire.kalium.logic.feature.conversation.folder.ObserveUserFoldersUseCaseImpl
import com.wire.kalium.logic.feature.conversation.folder.RemoveConversationFromFavoritesUseCase
import com.wire.kalium.logic.feature.conversation.folder.RemoveConversationFromFavoritesUseCaseImpl
import com.wire.kalium.logic.feature.conversation.folder.RemoveConversationFromFolderUseCase
import com.wire.kalium.logic.feature.conversation.folder.RemoveConversationFromFolderUseCaseImpl
import com.wire.kalium.logic.feature.conversation.guestroomlink.CanCreatePasswordProtectedLinksUseCase
import com.wire.kalium.logic.feature.conversation.guestroomlink.GenerateGuestRoomLinkUseCase
import com.wire.kalium.logic.feature.conversation.guestroomlink.GenerateGuestRoomLinkUseCaseImpl
Expand Down Expand Up @@ -390,4 +392,6 @@ class ConversationScope internal constructor(
get() = ObserveUserFoldersUseCaseImpl(conversationFolderRepository)
val moveConversationToFolder: MoveConversationToFolderUseCase
get() = MoveConversationToFolderUseCaseImpl(conversationFolderRepository)
val removeConversationFromFolder: RemoveConversationFromFolderUseCase
get() = RemoveConversationFromFolderUseCaseImpl(conversationFolderRepository)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*
* Wire
* Copyright (C) 2024 Wire Swiss GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see http://www.gnu.org/licenses/.
*/

package com.wire.kalium.logic.feature.conversation.folder

import com.wire.kalium.logic.CoreFailure
import com.wire.kalium.logic.data.conversation.folders.ConversationFolderRepository
import com.wire.kalium.logic.data.id.ConversationId
import com.wire.kalium.logic.functional.Either
import com.wire.kalium.logic.functional.flatMap
import com.wire.kalium.logic.functional.fold
import com.wire.kalium.util.KaliumDispatcher
import com.wire.kalium.util.KaliumDispatcherImpl
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext

/**
* This use case will remove a conversation from the selected folder and if the folder is empty, it will remove the folder.
*/
interface RemoveConversationFromFolderUseCase {
/**
* @param conversationId the id of the conversation
* @param folderId the id of the folder
* @return the [Result] indicating a successful operation, otherwise a [CoreFailure]
*/
suspend operator fun invoke(conversationId: ConversationId, folderId: String): Result

sealed interface Result {
data object Success : Result
data class Failure(val cause: CoreFailure) : Result
}
}

internal class RemoveConversationFromFolderUseCaseImpl(
private val conversationFolderRepository: ConversationFolderRepository,
private val dispatchers: KaliumDispatcher = KaliumDispatcherImpl
) : RemoveConversationFromFolderUseCase {
override suspend fun invoke(
conversationId: ConversationId,
folderId: String
): RemoveConversationFromFolderUseCase.Result = withContext(dispatchers.io) {
conversationFolderRepository.removeConversationFromFolder(conversationId, folderId)
.flatMap {
if (conversationFolderRepository.observeConversationsFromFolder(folderId).first().isEmpty()) {
conversationFolderRepository.removeFolder(folderId)
} else {
Either.Right(Unit)
}
}
.flatMap {
conversationFolderRepository.syncConversationFoldersFromLocal()
}
.fold({
RemoveConversationFromFolderUseCase.Result.Failure(it)
}, {
RemoveConversationFromFolderUseCase.Result.Success
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,20 @@ class ConversationFolderRepositoryTest {
coVerify { arrangement.conversationFolderDAO.getFoldersWithConversations() }.wasInvoked()
}

@Test
fun givenValidFolderIdWhenRemovingFolderThenShouldRemoveSuccessfully() = runTest {
// given
val folderId = "folder1"
val arrangement = Arrangement().withSuccessfulFolderRemoval()

// when
val result = arrangement.repository.removeFolder(folderId)

// then
result.shouldSucceed()
coVerify { arrangement.conversationFolderDAO.removeFolder(eq(folderId)) }.wasInvoked()
}

private class Arrangement {

@Mock
Expand Down Expand Up @@ -278,5 +292,10 @@ class ConversationFolderRepositoryTest {
coEvery { conversationFolderDAO.removeConversationFromFolder(any(), any()) }.returns(Unit)
return this
}

suspend fun withSuccessfulFolderRemoval(): Arrangement {
coEvery { conversationFolderDAO.removeFolder(any()) }.returns(Unit)
return this
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
/*
* Wire
* Copyright (C) 2024 Wire Swiss GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see http://www.gnu.org/licenses/.
*/
package com.wire.kalium.logic.feature.conversation.folder

import com.wire.kalium.logic.CoreFailure
import com.wire.kalium.logic.data.conversation.ConversationDetailsWithEvents
import com.wire.kalium.logic.data.conversation.folders.ConversationFolderRepository
import com.wire.kalium.logic.data.id.ConversationId
import com.wire.kalium.logic.feature.conversation.folder.RemoveConversationFromFolderUseCase.Result
import com.wire.kalium.logic.framework.TestConversationDetails
import com.wire.kalium.logic.functional.Either
import io.mockative.Mock
import io.mockative.coEvery
import io.mockative.coVerify
import io.mockative.mock
import io.mockative.once
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertIs

class RemoveConversationFromFolderUseCaseTest {

@Test
fun givenValidConversationAndFolder_WhenRemoveAndSyncSuccessful_ThenReturnSuccess() = runTest {
val testConversationId = ConversationId("conversation-value", "conversation-domain")
val testFolderId = "test-folder-id"

val (arrangement, removeConversationUseCase) = Arrangement()
.withRemoveConversationFromFolder(testConversationId, testFolderId, Either.Right(Unit))
.withObserveConversationsFromFolder(testFolderId, flowOf(emptyList()))
.withRemoveFolder(testFolderId, Either.Right(Unit))
.withSyncFolders(Either.Right(Unit))
.arrange()

val result = removeConversationUseCase(testConversationId, testFolderId)

assertIs<Result.Success>(result)

coVerify {
arrangement.conversationFolderRepository.removeConversationFromFolder(testConversationId, testFolderId)
}.wasInvoked(exactly = once)

coVerify {
arrangement.conversationFolderRepository.observeConversationsFromFolder(testFolderId)
}.wasInvoked(exactly = once)

coVerify {
arrangement.conversationFolderRepository.removeFolder(testFolderId)
}.wasInvoked(exactly = once)

coVerify {
arrangement.conversationFolderRepository.syncConversationFoldersFromLocal()
}.wasInvoked(exactly = once)
}

@Test
fun givenFolderNotEmpty_WhenRemoveAndSyncSuccessful_ThenReturnSuccessWithoutFolderRemoval() = runTest {
val testConversationId = ConversationId("conversation-value", "conversation-domain")
val testFolderId = "test-folder-id"

val (arrangement, removeConversationUseCase) = Arrangement()
.withRemoveConversationFromFolder(testConversationId, testFolderId, Either.Right(Unit))
.withObserveConversationsFromFolder(
testFolderId,
flowOf(listOf(ConversationDetailsWithEvents(TestConversationDetails.CONVERSATION_GROUP)))
)
.withSyncFolders(Either.Right(Unit))
.arrange()

val result = removeConversationUseCase(testConversationId, testFolderId)

assertIs<Result.Success>(result)

coVerify {
arrangement.conversationFolderRepository.removeConversationFromFolder(testConversationId, testFolderId)
}.wasInvoked(exactly = once)

coVerify {
arrangement.conversationFolderRepository.observeConversationsFromFolder(testFolderId)
}.wasInvoked(exactly = once)

coVerify {
arrangement.conversationFolderRepository.removeFolder(testFolderId)
}.wasNotInvoked()

coVerify {
arrangement.conversationFolderRepository.syncConversationFoldersFromLocal()
}.wasInvoked(exactly = once)
}

@Test
fun givenErrorDuringFolderRemoval_WhenObservedEmpty_ThenReturnFailure() = runTest {
val testConversationId = ConversationId("conversation-value", "conversation-domain")
val testFolderId = "test-folder-id"

val (arrangement, removeConversationUseCase) = Arrangement()
.withRemoveConversationFromFolder(testConversationId, testFolderId, Either.Right(Unit))
.withObserveConversationsFromFolder(testFolderId, flowOf(emptyList()))
.withRemoveFolder(testFolderId, Either.Left(CoreFailure.Unknown(null)))
.arrange()

val result = removeConversationUseCase(testConversationId, testFolderId)

assertIs<Result.Failure>(result)

coVerify {
arrangement.conversationFolderRepository.removeConversationFromFolder(testConversationId, testFolderId)
}.wasInvoked(exactly = once)

coVerify {
arrangement.conversationFolderRepository.observeConversationsFromFolder(testFolderId)
}.wasInvoked(exactly = once)

coVerify {
arrangement.conversationFolderRepository.removeFolder(testFolderId)
}.wasInvoked(exactly = once)
}

private class Arrangement {
@Mock
val conversationFolderRepository = mock(ConversationFolderRepository::class)

private val removeConversationFromFolderUseCase = RemoveConversationFromFolderUseCaseImpl(
conversationFolderRepository
)

suspend fun withRemoveConversationFromFolder(
conversationId: ConversationId,
folderId: String,
either: Either<CoreFailure, Unit>
) = apply {
coEvery {
conversationFolderRepository.removeConversationFromFolder(conversationId, folderId)
}.returns(either)
}

suspend fun withObserveConversationsFromFolder(
folderId: String,
flow: Flow<List<ConversationDetailsWithEvents>>
) = apply {
coEvery {
conversationFolderRepository.observeConversationsFromFolder(folderId)
}.returns(flow)
}

suspend fun withRemoveFolder(
folderId: String,
either: Either<CoreFailure, Unit>
) = apply {
coEvery {
conversationFolderRepository.removeFolder(folderId)
}.returns(either)
}

suspend fun withSyncFolders(either: Either<CoreFailure, Unit>) = apply {
coEvery {
conversationFolderRepository.syncConversationFoldersFromLocal()
}.returns(either)
}

fun arrange(block: Arrangement.() -> Unit = { }) = apply(block).let { this to removeConversationFromFolderUseCase }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ CREATE TABLE LabeledConversation (
conversation_id TEXT AS QualifiedIDEntity NOT NULL,
folder_id TEXT NOT NULL,

FOREIGN KEY (conversation_id) REFERENCES Conversation(qualified_id) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (folder_id) REFERENCES ConversationFolder(id) ON DELETE CASCADE ON UPDATE CASCADE,

PRIMARY KEY (folder_id, conversation_id)
Expand Down Expand Up @@ -68,3 +67,6 @@ DELETE FROM LabeledConversation;

clearFolders:
DELETE FROM ConversationFolder;

deleteFolder:
DELETE FROM ConversationFolder WHERE id = ?;
10 changes: 10 additions & 0 deletions persistence/src/commonMain/db_user/migrations/96.sqm
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
DROP TABLE LabeledConversation;

CREATE TABLE LabeledConversation (
conversation_id TEXT AS QualifiedIDEntity NOT NULL,
folder_id TEXT NOT NULL,

FOREIGN KEY (folder_id) REFERENCES ConversationFolder(id) ON DELETE CASCADE ON UPDATE CASCADE,

PRIMARY KEY (folder_id, conversation_id)
);
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,5 @@ interface ConversationFolderDAO {
suspend fun addConversationToFolder(conversationId: QualifiedIDEntity, folderId: String)
suspend fun removeConversationFromFolder(conversationId: QualifiedIDEntity, folderId: String)
suspend fun observeFolders(): Flow<List<ConversationFolderEntity>>
suspend fun removeFolder(folderId: String)
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import app.cash.sqldelight.coroutines.asFlow
import com.wire.kalium.persistence.ConversationFolder
import com.wire.kalium.persistence.ConversationFoldersQueries
import com.wire.kalium.persistence.GetAllFoldersWithConversations
import com.wire.kalium.persistence.LabeledConversation
import com.wire.kalium.persistence.dao.QualifiedIDEntity
import com.wire.kalium.persistence.dao.conversation.ConversationDetailsWithEventsEntity
import com.wire.kalium.persistence.dao.conversation.ConversationDetailsWithEventsMapper
Expand All @@ -45,6 +46,10 @@ class ConversationFolderDAOImpl internal constructor(
.flowOn(coroutineContext)
}

override suspend fun removeFolder(folderId: String) = withContext(coroutineContext) {
conversationFoldersQueries.deleteFolder(folderId)
}

override suspend fun getFoldersWithConversations(): List<FolderWithConversationsEntity> = withContext(coroutineContext) {
val labeledConversationList = conversationFoldersQueries.getAllFoldersWithConversations().executeAsList().map(::toEntity)

Expand All @@ -69,6 +74,11 @@ class ConversationFolderDAOImpl internal constructor(
conversationId = row.conversation_id
)

private fun toEntity(row: LabeledConversation) = ConversationLabelEntity(
folderId = row.folder_id,
conversationId = row.conversation_id
)

private fun toEntity(row: ConversationFolder) = ConversationFolderEntity(
id = row.id,
name = row.name,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ data class LabeledConversationEntity(
val conversationId: QualifiedIDEntity?
)

data class ConversationLabelEntity(
val conversationId: QualifiedIDEntity,
val folderId: String
)

enum class ConversationFolderTypeEntity {
USER,
FAVORITE
Expand Down
Loading

0 comments on commit b304f30

Please sign in to comment.