diff --git a/core/core-test/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/test/TestWebsocketProvider.kt b/core/core-test/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/test/TestWebsocketProvider.kt index 1cac139d7..f294651cb 100644 --- a/core/core-test/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/test/TestWebsocketProvider.kt +++ b/core/core-test/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/test/TestWebsocketProvider.kt @@ -14,7 +14,7 @@ val testWebsocketModule = module { single { TestWebsocketProvider() } } -private class TestWebsocketProvider : WebsocketProvider { +class TestWebsocketProvider : WebsocketProvider { override val connectionState: Flow = flowOf(WebsocketProvider.WebsocketConnectionState.WithSession(true)) diff --git a/core/datastore/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/datastore/defaults/ArtemisInstances.kt b/core/datastore/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/datastore/defaults/ArtemisInstances.kt index 97e5f3fbb..3b960af4b 100644 --- a/core/datastore/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/datastore/defaults/ArtemisInstances.kt +++ b/core/datastore/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/core/datastore/defaults/ArtemisInstances.kt @@ -31,7 +31,7 @@ object ArtemisInstances { ) private val TumTs1 = ArtemisInstance( - host = "artemis-test1.artemis.in.tum.de", + host = "artemis-test1.artemis.cit.tum.de", name = R.string.artemis_instance_tum_test_server_1, type = ArtemisInstance.Type.TEST ) diff --git a/feature/course-view/src/debug/kotlin/de/tum/informatics/www1/artemis/native_app/feature/courseview/MessagingScreenshots.kt b/feature/course-view/src/debug/kotlin/de/tum/informatics/www1/artemis/native_app/feature/courseview/MessagingScreenshots.kt index 48a4b1117..7db65a3ab 100644 --- a/feature/course-view/src/debug/kotlin/de/tum/informatics/www1/artemis/native_app/feature/courseview/MessagingScreenshots.kt +++ b/feature/course-view/src/debug/kotlin/de/tum/informatics/www1/artemis/native_app/feature/courseview/MessagingScreenshots.kt @@ -243,6 +243,7 @@ fun `Metis - Conversation Channel`() { ), clientId = 0L, hasModerationRights = true, + isAtLeastTutorInCourse = true, listContentPadding = PaddingValues(), serverUrl = "", courseId = 0, diff --git a/feature/metis-test/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metistest/MetisServiceStub.kt b/feature/metis-test/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metistest/MetisServiceStub.kt new file mode 100644 index 000000000..677e86c8f --- /dev/null +++ b/feature/metis-test/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metistest/MetisServiceStub.kt @@ -0,0 +1,38 @@ +package de.tum.informatics.www1.artemis.native_app.feature.metistest + +import de.tum.informatics.www1.artemis.native_app.core.data.NetworkResponse +import de.tum.informatics.www1.artemis.native_app.core.websocket.WebsocketProvider +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.network.MetisService +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.MetisContext +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.MetisPostDTO +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.StandalonePost +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +class MetisServiceStub( + var posts: List = emptyList() +): MetisService { + + override suspend fun getPosts( + standalonePostsContext: MetisService.StandalonePostsContext, + pageSize: Int, + pageNum: Int, + authToken: String, + serverUrl: String + ): NetworkResponse> { + return NetworkResponse.Response(posts) + } + + override suspend fun getPost( + metisContext: MetisContext, + serverSidePostId: Long, + serverUrl: String, + authToken: String + ): NetworkResponse { + return NetworkResponse.Response(posts.first()) + } + + override fun subscribeToPostUpdates(metisContext: MetisContext): Flow> { + return flowOf() + } +} \ No newline at end of file diff --git a/feature/metis/conversation/build.gradle.kts b/feature/metis/conversation/build.gradle.kts index 9058f8386..498c87285 100644 --- a/feature/metis/conversation/build.gradle.kts +++ b/feature/metis/conversation/build.gradle.kts @@ -36,6 +36,9 @@ dependencies { testImplementation(project(":feature:metis-test")) implementation("androidx.paging:paging-common:3.2.1") + + testImplementation(libs.mockk.android) + testImplementation(libs.mockk.agent) } tasks.register("fetchAndPrepareEmojis", emoji.FetchAndPrepareEmojisTask::class) { diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/ConversationViewModel.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/ConversationViewModel.kt index 8a7bf3e49..2e4617d7f 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/ConversationViewModel.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/ConversationViewModel.kt @@ -20,6 +20,7 @@ import de.tum.informatics.www1.artemis.native_app.core.datastore.ServerConfigura import de.tum.informatics.www1.artemis.native_app.core.datastore.authToken import de.tum.informatics.www1.artemis.native_app.core.device.NetworkStatusProvider import de.tum.informatics.www1.artemis.native_app.core.model.Course +import de.tum.informatics.www1.artemis.native_app.core.model.account.isAtLeastTutorInCourse import de.tum.informatics.www1.artemis.native_app.core.model.exercise.FileUploadExercise import de.tum.informatics.www1.artemis.native_app.core.model.exercise.ModelingExercise import de.tum.informatics.www1.artemis.native_app.core.model.exercise.ProgrammingExercise @@ -149,6 +150,21 @@ internal open class ConversationViewModel( metisStorageService = metisStorageService ) + private val course: StateFlow> = flatMapLatest( + serverConfigurationService.serverUrl, + accountService.authToken, + onRequestReload.onStart { emit(Unit) } + ) { serverUrl, authToken, _ -> + retryOnInternet(networkStatusProvider.currentNetworkStatus) { + courseService.getCourse( + metisContext.courseId, + serverUrl, + authToken + ).bind { it.course } + } + } + .stateIn(viewModelScope + coroutineContext, SharingStarted.Lazily) + val hasModerationRights: StateFlow = flatMapLatest( serverConfigurationService.serverUrl, accountService.authToken, @@ -168,6 +184,22 @@ internal open class ConversationViewModel( } .stateIn(viewModelScope + coroutineContext, SharingStarted.Eagerly, false) + val isAtLeastTutorInCourse: StateFlow = flatMapLatest( + serverConfigurationService.serverUrl, + accountService.authToken, + course, + onRequestReload.onStart { emit(Unit) } + ) { serverUrl, authToken, course, _ -> + retryOnInternet(networkStatusProvider.currentNetworkStatus) { + accountDataService.getAccountData( + serverUrl = serverUrl, + bearerToken = authToken + ) + .bind { it.isAtLeastTutorInCourse(course = course.orThrow()) } + } + .map { it.orElse(false) } + } + .stateIn(viewModelScope + coroutineContext, SharingStarted.Eagerly, false) val conversationDataStatus: StateFlow = combine( websocketProvider.isConnected, @@ -210,21 +242,6 @@ internal open class ConversationViewModel( } .stateIn(viewModelScope + coroutineContext, SharingStarted.Eagerly) - private val course: StateFlow> = flatMapLatest( - serverConfigurationService.serverUrl, - accountService.authToken, - onRequestReload.onStart { emit(Unit) } - ) { serverUrl, authToken, _ -> - retryOnInternet(networkStatusProvider.currentNetworkStatus) { - courseService.getCourse( - metisContext.courseId, - serverUrl, - authToken - ).bind { it.course } - } - } - .stateIn(viewModelScope + coroutineContext, SharingStarted.Lazily) - private val conversations: StateFlow>> = flatMapLatest( serverConfigurationService.serverUrl, accountService.authToken, @@ -325,6 +342,32 @@ internal open class ConversationViewModel( return if (success) null else MetisModificationFailure.DELETE_REACTION } + /** + * Handles a click on resolve or does not resolve post. + * It updates the post accordingly. + */ + fun toggleResolvePost( + parentPost: PostPojo, + post: AnswerPostPojo + ): Deferred { + return viewModelScope.async(coroutineContext) { + val conversation = + loadConversation() ?: return@async MetisModificationFailure.UPDATE_POST + + val resolved = !post.resolvesPost + val serializedParentPost = StandalonePost(parentPost, conversation) + val newPost = AnswerPost(post, serializedParentPost).copy(resolvesPost = resolved) + + metisModificationService.updateAnswerPost( + context = metisContext, + post = newPost, + serverUrl = serverConfigurationService.serverUrl.first(), + authToken = accountService.authToken.first() + ) + .asMetisModificationFailure(MetisModificationFailure.UPDATE_POST) + } + } + fun deletePost(post: IBasePost): Deferred { return viewModelScope.async(coroutineContext) { metisModificationService.deletePost( @@ -420,12 +463,9 @@ internal open class ConversationViewModel( ) { authToken, serverUrl -> retryOnInternet(networkStatusProvider.currentNetworkStatus) { conversationService - .searchForPotentialCommunicationParticipants( + .searchForCourseMembers( courseId = metisContext.courseId, query = query, - includeStudents = true, - includeTutors = true, - includeInstructors = true, authToken = authToken, serverUrl = serverUrl ) diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/chatlist/ChatListItem.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/chatlist/ChatListItem.kt index 1fa5f8c05..e2a754228 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/chatlist/ChatListItem.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/chatlist/ChatListItem.kt @@ -1,7 +1,6 @@ package de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.chatlist import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.IStandalonePost -import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.db.pojo.PostPojo import kotlinx.datetime.LocalDate sealed class ChatListItem { diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/chatlist/MetisChatList.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/chatlist/MetisChatList.kt index 31fd21b6c..7ca2ce376 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/chatlist/MetisChatList.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/chatlist/MetisChatList.kt @@ -53,6 +53,7 @@ import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.toJavaInstant +import org.koin.compose.koinInject import java.text.SimpleDateFormat import java.util.Date @@ -75,6 +76,7 @@ internal fun MetisChatList( val clientId: Long by viewModel.clientIdOrDefault.collectAsState() val hasModerationRights by viewModel.hasModerationRights.collectAsState() + val isAtLeastTutorInCourse by viewModel.isAtLeastTutorInCourse.collectAsState() val serverUrl by viewModel.serverUrl.collectAsState() @@ -94,6 +96,7 @@ internal fun MetisChatList( posts = posts.asPostsDataState(), clientId = clientId, hasModerationRights = hasModerationRights, + isAtLeastTutorInCourse = isAtLeastTutorInCourse, listContentPadding = listContentPadding, serverUrl = serverUrl, courseId = viewModel.courseId, @@ -119,6 +122,7 @@ fun MetisChatList( bottomItem: PostPojo?, clientId: Long, hasModerationRights: Boolean, + isAtLeastTutorInCourse: Boolean, listContentPadding: PaddingValues, serverUrl: String, courseId: Long, @@ -137,9 +141,10 @@ fun MetisChatList( initialReplyTextProvider = initialReplyTextProvider, onCreatePost = onCreatePost, onEditPost = onEditPost, + onResolvePost = null, onDeletePost = onDeletePost, - onRequestReactWithEmoji = onRequestReactWithEmoji - ) { replyMode, onEditPostDelegate, onRequestReactWithEmojiDelegate, onDeletePostDelegate, updateFailureStateDelegate -> + onRequestReactWithEmoji = onRequestReactWithEmoji, + ) { replyMode, onEditPostDelegate, _, onRequestReactWithEmojiDelegate, onDeletePostDelegate, updateFailureStateDelegate -> Column(modifier = modifier) { val informationModifier = Modifier .fillMaxSize() @@ -155,7 +160,8 @@ fun MetisChatList( itemCount = posts.itemCount, order = DisplayPostOrder.REVERSED, bottomItem = bottomItem, - imageLoaderCreation = imageLoaderCreation + imageLoaderCreation = imageLoaderCreation, + emojiService = koinInject() ) { when (posts) { PostsDataState.Empty -> { @@ -186,6 +192,7 @@ fun MetisChatList( clientId = clientId, onClickViewPost = onClickViewPost, hasModerationRights = hasModerationRights, + isAtLeastTutorInCourse = isAtLeastTutorInCourse, onRequestEdit = onEditPostDelegate, onRequestDelete = onDeletePostDelegate, onRequestReactWithEmoji = onRequestReactWithEmojiDelegate, @@ -214,6 +221,7 @@ private fun ChatList( state: LazyListState, posts: PostsDataState.Loaded, hasModerationRights: Boolean, + isAtLeastTutorInCourse: Boolean, clientId: Long, onClickViewPost: (StandalonePostId) -> Unit, onRequestEdit: (IStandalonePost) -> Unit, @@ -246,6 +254,7 @@ private fun ChatList( val postActions = rememberPostActions( post = post, hasModerationRights = hasModerationRights, + isAtLeastTutorInCourse = isAtLeastTutorInCourse, clientId = clientId, onRequestEdit = { onRequestEdit(post ?: return@rememberPostActions) }, onRequestDelete = { @@ -257,6 +266,7 @@ private fun ChatList( onReplyInThread = { onClickViewPost(post?.standalonePostId ?: return@rememberPostActions) }, + onResolvePost = null, onRequestRetrySend = { onRequestRetrySend( post?.standalonePostId ?: return@rememberPostActions diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/chatlist/MetisPostListHandler.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/chatlist/MetisPostListHandler.kt index e1e377dc2..2452fa5b2 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/chatlist/MetisPostListHandler.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/chatlist/MetisPostListHandler.kt @@ -25,6 +25,8 @@ import coil.ImageLoader import de.tum.informatics.www1.artemis.native_app.core.common.markdown.PostArtemisMarkdownTransformer import de.tum.informatics.www1.artemis.native_app.core.ui.markdown.LocalMarkdownTransformer import de.tum.informatics.www1.artemis.native_app.core.ui.markdown.ProvideMarkwon +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.EmojiService +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.impl.EmojiServiceStub import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.ProvideEmojis import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.post.DisplayPostOrder import kotlinx.coroutines.Deferred @@ -45,6 +47,7 @@ internal fun MetisPostListHandler( bottomItem: T?, order: DisplayPostOrder, imageLoaderCreation: (Context) -> Deferred, + emojiService: EmojiService, content: @Composable BoxScope.() -> Unit ) { val scope = rememberCoroutineScope() @@ -127,7 +130,7 @@ internal fun MetisPostListHandler( } ProvideMarkwon(imageLoader = imageLoader) { - ProvideEmojis { + ProvideEmojis(emojiService) { CompositionLocalProvider(LocalMarkdownTransformer provides markdownTransformer) { content() } diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/chatlist/MetisSearchPagingSource.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/chatlist/MetisSearchPagingSource.kt index 8bf5dbd83..6ef43b33d 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/chatlist/MetisSearchPagingSource.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/chatlist/MetisSearchPagingSource.kt @@ -5,6 +5,7 @@ import androidx.paging.PagingState import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.network.MetisService import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.StandalonePost + class MetisSearchPagingSource( private val metisService: MetisService, private val context: MetisService.StandalonePostsContext, @@ -30,8 +31,12 @@ class MetisSearchPagingSource( ) .map( mapSuccess = { loadedPosts -> + // Currently the server returns duplicate posts when searching for posts. This + // caused a crash in the UI. + // TODO: https://github.com/ls1intum/artemis-android/issues/99 + val filteredPosts = loadedPosts.distinctBy { it.id } LoadResult.Page( - data = loadedPosts, + data = filteredPosts, prevKey = if (page > 0) page - 1 else null, nextKey = if (loadedPosts.size == params.loadSize) page + 1 else null ) diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/PostActions.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/PostActions.kt index 702c30a19..d2bfdfb21 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/PostActions.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/PostActions.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.AnnotatedString +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.IAnswerPost import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.IBasePost data class PostActions( @@ -12,6 +13,7 @@ data class PostActions( val onClickReaction: ((emojiId: String, create: Boolean) -> Unit)? = null, val onCopyText: () -> Unit = {}, val onReplyInThread: (() -> Unit)? = null, + val onResolvePost: (() -> Unit)? = null, val onRequestRetrySend: () -> Unit = {} ) { val canPerformAnyAction: Boolean get() = requestDeletePost != null || requestEditPost != null @@ -21,11 +23,13 @@ data class PostActions( fun rememberPostActions( post: IBasePost?, hasModerationRights: Boolean, + isAtLeastTutorInCourse: Boolean, clientId: Long, onRequestEdit: () -> Unit, onRequestDelete: () -> Unit, onClickReaction: (emojiId: String, create: Boolean) -> Unit, onReplyInThread: (() -> Unit)?, + onResolvePost: (() -> Unit)?, onRequestRetrySend: () -> Unit ): PostActions { val clipboardManager = LocalClipboardManager.current @@ -38,12 +42,14 @@ fun rememberPostActions( onRequestDelete, onClickReaction, onReplyInThread, + onResolvePost, onRequestRetrySend, clipboardManager ) { if (post != null) { val doesPostExistOnServer = post.serverPostId != null val hasEditPostRights = hasModerationRights || post.authorId == clientId + val hasResolvePostRights = isAtLeastTutorInCourse || post.authorId == clientId PostActions( requestEditPost = if (doesPostExistOnServer && hasEditPostRights) onRequestEdit else null, @@ -53,6 +59,7 @@ fun rememberPostActions( clipboardManager.setText(AnnotatedString(post.content.orEmpty())) }, onReplyInThread = if (doesPostExistOnServer) onReplyInThread else null, + onResolvePost = if (hasResolvePostRights) onResolvePost else null, onRequestRetrySend = onRequestRetrySend ) } else { diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/PostContextBottomSheet.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/PostContextBottomSheet.kt index c8a9266c5..474f6a443 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/PostContextBottomSheet.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/PostContextBottomSheet.kt @@ -18,6 +18,8 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AddReaction +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit @@ -52,6 +54,8 @@ import de.tum.informatics.www1.artemis.native_app.core.ui.Spacings import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.R import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.LocalEmojiProvider import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.getUnicodeForEmojiId +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.AnswerPost +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.IAnswerPost import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.IBasePost import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.IReaction @@ -136,6 +140,18 @@ internal fun PostContextBottomSheet( } ) + if (postActions.onResolvePost != null && post is IAnswerPost) { + ActionButton( + modifier = actionButtonModifier, + icon = if (post.resolvesPost) Icons.Default.Clear else Icons.Default.Check, + text = if (post.resolvesPost) stringResource(id = R.string.post_does_not_resolve) else stringResource(id = R.string.post_resolves), + onClick = { + onDismissRequest() + postActions.onResolvePost.invoke() + } + ) + } + postActions.onReplyInThread?.let { ActionButton( modifier = actionButtonModifier, diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/PostItem.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/PostItem.kt index 36f0823f9..5df625238 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/PostItem.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/post/PostItem.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.School import androidx.compose.material.icons.filled.SupervisorAccount @@ -37,6 +38,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -51,6 +53,7 @@ import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui. import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.IAnswerPost import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.IBasePost import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.IReaction +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.IStandalonePost import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.UserRole import io.github.fornewid.placeholder.material3.placeholder import kotlinx.datetime.Clock @@ -116,7 +119,7 @@ internal fun PostItem( it .background(color = MaterialTheme.colorScheme.errorContainer) .clickable(onClick = onRequestRetrySend) - } else Modifier + } else modifier .combinedClickable( onClick = onClick, onLongClick = onLongClick @@ -148,7 +151,6 @@ internal fun PostItem( modifier = Modifier .fillMaxWidth() .placeholder(visible = isPlaceholder), - maxLines = 5, style = MaterialTheme.typography.bodyMedium, onClick = onClick, onLongClick = onLongClick, @@ -162,6 +164,26 @@ internal fun PostItem( color = EditedGray ) } + + when (post) { + is IStandalonePost -> { + if (post.resolved == true) { + ResolvedLabel( + modifier = Modifier.fillMaxWidth(), + resourceString = R.string.post_is_resolved + ) + } + } + is IAnswerPost -> { + if (post.resolvesPost) { + ResolvedLabel( + modifier = Modifier.fillMaxWidth(), + resourceString = R.string.post_resolves + ) + } + } + else -> {} + } } StandalonePostFooter( @@ -247,6 +269,30 @@ private fun PostHeadline( } } +@Composable +private fun ResolvedLabel( + modifier: Modifier, + resourceString: Int +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + Icons.Default.Check, + modifier = Modifier + .size(16.dp) + .fillMaxSize(), + contentDescription = null + ) + Text( + text = stringResource(id = resourceString), + style = MaterialTheme.typography.bodySmall + ) + } +} + @Composable private fun HeadlineAuthorInfo( modifier: Modifier, diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/MarkdownTextField.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/MarkdownTextField.kt index 0f67f4ebb..7a1a766cd 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/MarkdownTextField.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/MarkdownTextField.kt @@ -21,12 +21,16 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import de.tum.informatics.www1.artemis.native_app.core.ui.markdown.MarkdownText import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.R + +const val TEST_TAG_MARKDOWN_TEXTFIELD = "TEST_TAG_MARKDOWN_TEXTFIELD" + /** * @param sendButton composable centered vertically right to the text field. */ @@ -92,7 +96,8 @@ internal fun MarkdownTextField( onFocusLost() hadFocus = false } - }, + } + .testTag(TEST_TAG_MARKDOWN_TEXTFIELD), value = textFieldValue, onValueChange = onTextChanged, ) diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/ReplyMode.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/ReplyMode.kt index b505b98d3..1bfe598bf 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/ReplyMode.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/ReplyMode.kt @@ -38,7 +38,7 @@ internal sealed class ReplyMode() { private val onEditMessage: (String) -> Deferred, val onCancelEditMessage: () -> Unit ) : ReplyMode() { - override val currentText = mutableStateOf(TextFieldValue()) + override val currentText = mutableStateOf(TextFieldValue(post.content ?: "")) override fun onUpdate(new: TextFieldValue) { currentText.value = new @@ -96,17 +96,21 @@ private fun rememberReplyMode( /** * Holds the necessary data about the reply mode and tasks going on. Exposes methods to update the associated states over the content lambda. + * TODO https://github.com/ls1intum/artemis-android/issues/64: + * MetisReplyHandler is not efficient to use, refactoring is needed. */ @Composable internal fun MetisReplyHandler( initialReplyTextProvider: InitialReplyTextProvider, onCreatePost: () -> Deferred, onEditPost: (T, String) -> Deferred, + onResolvePost: ((T) -> Deferred)?, onDeletePost: (T) -> Deferred, onRequestReactWithEmoji: (T, emojiId: String, create: Boolean) -> Deferred, content: @Composable ( replyMode: ReplyMode, onRequestEditPostDelegate: (T) -> Unit, + onRequestResolvePostDelegate: (T) -> Unit, onRequestReactWithEmojiDelegate: (T, emojiId: String, create: Boolean) -> Unit, onDeletePostDelegate: (T) -> Unit, updateFailureStateDelegate: (MetisModificationFailure?) -> Unit @@ -136,6 +140,11 @@ internal fun MetisReplyHandler( content( replyMode, { post -> editingPost = post }, + { post -> + if (onResolvePost != null) { + metisModificationTask = onResolvePost(post) + } + }, { post, emojiId, create -> metisModificationTask = onRequestReactWithEmoji(post, emojiId, create) }, diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/ReplyTextField.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/ReplyTextField.kt index 83cc9c20a..54be4f05c 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/ReplyTextField.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/ReplyTextField.kt @@ -42,6 +42,7 @@ import kotlin.time.Duration.Companion.seconds internal const val TEST_TAG_CAN_CREATE_REPLY = "TEST_TAG_CAN_CREATE_REPLY" internal const val TEST_TAG_REPLY_TEXT_FIELD = "TEST_TAG_REPLY_TEXT_FIELD" internal const val TEST_TAG_REPLY_SEND_BUTTON = "TEST_TAG_REPLY_SEND_BUTTON" +internal const val TEST_TAG_UNFOCUSED_TEXT_FIELD = "TEST_TAG_UNFOCUSED_TEXT_FIED" private const val DisabledContentAlpha = 0.75f @@ -443,7 +444,8 @@ private fun UnfocusedPreviewReplyTextField(onRequestShowTextField: () -> Unit, t modifier = Modifier .fillMaxWidth() .clickable(onClick = onRequestShowTextField) - .padding(horizontal = 16.dp), + .padding(horizontal = 16.dp) + .testTag(TEST_TAG_UNFOCUSED_TEXT_FIELD), verticalAlignment = Alignment.CenterVertically ) { Text( diff --git a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/thread/MetisThreadUi.kt b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/thread/MetisThreadUi.kt index c8ef020f0..76eec36a4 100644 --- a/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/thread/MetisThreadUi.kt +++ b/feature/metis/conversation/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/thread/MetisThreadUi.kt @@ -37,6 +37,7 @@ import de.tum.informatics.www1.artemis.native_app.core.data.orNull import de.tum.informatics.www1.artemis.native_app.core.ui.common.BasicDataStateUi import de.tum.informatics.www1.artemis.native_app.core.ui.markdown.ProvideMarkwon import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.R +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.EmojiService import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.MetisModificationFailure import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.ConversationViewModel import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.ProvideEmojis @@ -47,17 +48,21 @@ import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui. import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.post.PostWithBottomSheet import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.post.rememberPostActions import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.post.shouldDisplayHeader +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.reply.InitialReplyTextProvider import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.reply.MetisReplyHandler import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.reply.ReplyTextField import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.shared.isReplyEnabled +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.IAnswerPost import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.visiblemetiscontextreporter.ReportVisibleMetisContext import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.visiblemetiscontextreporter.VisibleStandalonePostDetails import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.IBasePost +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.conversation.Conversation import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.db.pojo.AnswerPostPojo import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.db.pojo.PostPojo import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.ui.humanReadableName import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Deferred +import org.koin.compose.koinInject internal const val TEST_TAG_THREAD_LIST = "TEST_TAG_THREAD_LIST" internal fun testTagForAnswerPost(answerPostId: String) = "answerPost$answerPostId" @@ -75,6 +80,7 @@ internal fun MetisThreadUi( val serverUrl by viewModel.serverUrl.collectAsState() val hasModerationRights by viewModel.hasModerationRights.collectAsState() + val isAtLeastTutorInCourse by viewModel.isAtLeastTutorInCourse.collectAsState() postDataState.bind { it.serverPostId }.orNull()?.let { serverSidePostId -> ReportVisibleMetisContext( @@ -88,9 +94,75 @@ internal fun MetisThreadUi( } val conversationDataState by viewModel.conversation.collectAsState() - val isReplyEnabled = isReplyEnabled(conversationDataState = conversationDataState) + MetisThreadUi( + modifier = modifier, + courseId = viewModel.courseId, + initialReplyTextProvider = viewModel, + conversationDataState = conversationDataState, + postDataState = postDataState, + isAtLeastTutorInCourse = isAtLeastTutorInCourse, + hasModerationRights = hasModerationRights, + serverUrl = serverUrl, + emojiService = koinInject(), + clientId = clientId, + onCreatePost = viewModel::createPost, + onEditPost = { post, newText -> + val parentPost = postDataState.orNull() + + when (post) { + is AnswerPostPojo -> { + if (parentPost == null) CompletableDeferred( + MetisModificationFailure.UPDATE_POST + ) else viewModel.editAnswerPost(parentPost, post, newText) + } + + is PostPojo -> viewModel.editPost(post, newText) + else -> throw NotImplementedError() + } + }, + onResolvePost = { post -> + val parentPost = postDataState.orNull() + + if (post is AnswerPostPojo) { + if (parentPost == null) CompletableDeferred( + MetisModificationFailure.UPDATE_POST + ) else viewModel.toggleResolvePost(parentPost, post) + } else { + throw NotImplementedError() + } + }, + onDeletePost = viewModel::deletePost, + onRequestReactWithEmoji = viewModel::createOrDeleteReaction, + onRequestReload = viewModel::requestReload, + onRequestRetrySend = viewModel::retryCreateReply, + imageLoaderCreation = viewModel::createMarkdownImageLoader + ) +} + +@Composable +internal fun MetisThreadUi( + modifier: Modifier, + courseId: Long, + clientId: Long, + postDataState: DataState, + conversationDataState: DataState, + hasModerationRights: Boolean, + isAtLeastTutorInCourse: Boolean, + serverUrl: String, + emojiService: EmojiService, + initialReplyTextProvider: InitialReplyTextProvider, + imageLoaderCreation: (Context) -> Deferred, + onCreatePost: () -> Deferred, + onEditPost: (IBasePost, String) -> Deferred, + onResolvePost: ((IBasePost) -> Deferred)?, + onDeletePost: (IBasePost) -> Deferred, + onRequestReactWithEmoji: (IBasePost, emojiId: String, create: Boolean) -> Deferred, + onRequestReload: () -> Unit, + onRequestRetrySend: (clientSidePostId: String, content: String) -> Unit +) { val listState = rememberLazyListState() + val isReplyEnabled = isReplyEnabled(conversationDataState = conversationDataState) val title by remember(conversationDataState) { derivedStateOf { @@ -98,28 +170,15 @@ internal fun MetisThreadUi( } } - ProvideEmojis { + ProvideEmojis(emojiService) { MetisReplyHandler( - initialReplyTextProvider = viewModel, - onCreatePost = viewModel::createReply, - onEditPost = { post, newText -> - val parentPost = postDataState.orNull() - - when (post) { - is AnswerPostPojo -> { - if (parentPost == null) return@MetisReplyHandler CompletableDeferred( - MetisModificationFailure.UPDATE_POST - ) - viewModel.editAnswerPost(parentPost, post, newText) - } - - is PostPojo -> viewModel.editPost(post, newText) - else -> throw NotImplementedError() - } - }, - onDeletePost = viewModel::deletePost, - onRequestReactWithEmoji = viewModel::createOrDeleteReaction - ) { replyMode, onEditPostDelegate, onRequestReactWithEmojiDelegate, onDeletePostDelegate, updateFailureStateDelegate -> + initialReplyTextProvider = initialReplyTextProvider, + onCreatePost = onCreatePost, + onEditPost = onEditPost, + onResolvePost = onResolvePost, + onDeletePost = onDeletePost, + onRequestReactWithEmoji = onRequestReactWithEmoji + ) { replyMode, onEditPostDelegate, onResolvePostDelegate, onRequestReactWithEmojiDelegate, onDeletePostDelegate, updateFailureStateDelegate -> BoxWithConstraints(modifier = modifier) { Column(modifier = Modifier.fillMaxSize()) { BasicDataStateUi( @@ -130,17 +189,18 @@ internal fun MetisThreadUi( loadingText = stringResource(id = R.string.standalone_post_loading), failureText = stringResource(id = R.string.standalone_post_failure), retryButtonText = stringResource(id = R.string.standalone_post_try_again), - onClickRetry = viewModel::requestReload + onClickRetry = onRequestReload ) { post -> MetisPostListHandler( modifier = Modifier.fillMaxSize(), serverUrl = serverUrl, - courseId = viewModel.courseId, + courseId = courseId, state = listState, itemCount = post.orderedAnswerPostings.size, order = DisplayPostOrder.REGULAR, + emojiService = emojiService, bottomItem = post.orderedAnswerPostings.lastOrNull(), - imageLoaderCreation = viewModel::createMarkdownImageLoader, + imageLoaderCreation = imageLoaderCreation, ) { PostAndRepliesList( modifier = Modifier @@ -148,13 +208,15 @@ internal fun MetisThreadUi( .testTag(TEST_TAG_THREAD_LIST), post = post, hasModerationRights = hasModerationRights, + isAtLeastTutorInCourse = isAtLeastTutorInCourse, clientId = clientId, onRequestReactWithEmoji = onRequestReactWithEmojiDelegate, onRequestEdit = onEditPostDelegate, onRequestDelete = onDeletePostDelegate, + onRequestResolve = onResolvePostDelegate, state = listState, - imageLoaderCreation = viewModel::createMarkdownImageLoader, - onRequestRetrySend = viewModel::retryCreateReply + imageLoaderCreation = imageLoaderCreation, + onRequestRetrySend = onRequestRetrySend ) } } @@ -181,10 +243,12 @@ private fun PostAndRepliesList( state: LazyListState, post: PostPojo, hasModerationRights: Boolean, + isAtLeastTutorInCourse: Boolean, clientId: Long, imageLoaderCreation: (Context) -> Deferred, onRequestEdit: (IBasePost) -> Unit, onRequestDelete: (IBasePost) -> Unit, + onRequestResolve: (IBasePost) -> Unit, onRequestReactWithEmoji: (IBasePost, emojiId: String, create: Boolean) -> Unit, onRequestRetrySend: (clientSidePostId: String, content: String) -> Unit ) { @@ -192,6 +256,7 @@ private fun PostAndRepliesList( rememberPostActions( affectedPost, hasModerationRights, + isAtLeastTutorInCourse, clientId, onRequestEdit = { onRequestEdit(affectedPost) }, onRequestDelete = { onRequestDelete(affectedPost) }, @@ -203,6 +268,7 @@ private fun PostAndRepliesList( ) }, onReplyInThread = null, + onResolvePost = { onRequestResolve(affectedPost) }, onRequestRetrySend = { onRequestRetrySend( affectedPost.clientPostId ?: return@rememberPostActions, diff --git a/feature/metis/conversation/src/main/res/values/post_strings.xml b/feature/metis/conversation/src/main/res/values/post_strings.xml index f0aa848d0..5225180ed 100644 --- a/feature/metis/conversation/src/main/res/values/post_strings.xml +++ b/feature/metis/conversation/src/main/res/values/post_strings.xml @@ -4,6 +4,9 @@ Delete post Copy text Reply in thread + Resolves Post + Doesn\'t Resolve Post + Resolved (edited) diff --git a/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ConversationAnswerMessagesE2eTest.kt b/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ConversationAnswerMessagesE2eTest.kt index f8fd7a0d7..e2c19cb53 100644 --- a/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ConversationAnswerMessagesE2eTest.kt +++ b/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ConversationAnswerMessagesE2eTest.kt @@ -20,7 +20,7 @@ import kotlin.time.Duration.Companion.milliseconds class ConversationAnswerMessagesE2eTest : ConversationMessagesBaseTest() { @Test(timeout = DefaultTestTimeoutMillis) - fun `shows existing message with answer posts`() { + fun `test GIVEN existing answer posts WHEN displaying the parent post THEN it shows all the answer posts`() { val answerPostContents = (0 until 3).map { "answer post content $it" } @@ -64,4 +64,47 @@ class ConversationAnswerMessagesE2eTest : ConversationMessagesBaseTest() { } } } -} + + @Test(timeout = DefaultTestTimeoutMillis) + fun `test GIVEN an unresolved post with an answer message WHEN the answer post gets resolved THEN the post is marked as resolved`() { + runTest(timeout = DefaultTimeoutMillis.milliseconds * 4) { + val post = metisModificationService.createPost( + context = metisContext, + post = createPost("test message"), + serverUrl = testServerUrl, + authToken = accessToken + ).orThrow("Could not create message") + + val answerPost = metisModificationService.createAnswerPost( + context = metisContext, + post = de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.AnswerPost( + creationDate = Clock.System.now(), + content = "answer post test message", + resolvesPost = false, + post = post + ), + serverUrl = testServerUrl, + authToken = accessToken + ).orThrow("Could not create answer message with text $post") + + val newAnswerPost = answerPost.copy(resolvesPost = true) + + metisModificationService.updateAnswerPost( + context = metisContext, + post = newAnswerPost, + serverUrl = testServerUrl, + authToken = accessToken + ).orThrow("Could not resolve answer post") + + val updatedPost = metisService + .getPost(metisContext, post.serverPostId!!, testServerUrl, accessToken) + .orThrow("Could not download updated post") + + assertNotNull( + updatedPost.answers?.firstOrNull { it.id == answerPost.id && it.resolvesPost }, + "Answer post was not marked as resolved" + ) + } + } + +} \ No newline at end of file diff --git a/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ConversationAnswerMessagesUITest.kt b/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ConversationAnswerMessagesUITest.kt new file mode 100644 index 000000000..a4ba26e19 --- /dev/null +++ b/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ConversationAnswerMessagesUITest.kt @@ -0,0 +1,230 @@ +package de.tum.informatics.www1.artemis.native_app.feature.metis.conversation + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.hasAnyChild +import androidx.compose.ui.test.hasScrollAction +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onParent +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performScrollToIndex +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.printToLog +import de.tum.informatics.www1.artemis.native_app.core.common.test.EndToEndTest +import de.tum.informatics.www1.artemis.native_app.core.data.DataState +import de.tum.informatics.www1.artemis.native_app.core.model.Course +import de.tum.informatics.www1.artemis.native_app.core.test.BaseComposeTest +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.MetisModificationFailure +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.impl.EmojiServiceStub +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.thread.MetisThreadUi +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.thread.TEST_TAG_THREAD_LIST +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.IBasePost +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.UserRole +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.conversation.OneToOneChat +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.db.pojo.AnswerPostPojo +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.db.pojo.PostPojo +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.runBlocking +import kotlinx.datetime.Clock +import org.junit.Test +import org.junit.experimental.categories.Category +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@Category(EndToEndTest::class) +@RunWith(RobolectricTestRunner::class) +class ConversationAnswerMessagesUITest : BaseComposeTest() { + + private val clientId = 20L + + private val course: Course = Course(id = 1) + private val conversation = OneToOneChat(id = 2) + + private val answers = (0..2).map { index -> + AnswerPostPojo( + parentPostId = "client-id", + postId = "answer-client-id-$index", + resolvesPost = false, + basePostingCache = AnswerPostPojo.BasePostingCache( + serverPostId = index.toLong(), + authorId = clientId, + creationDate = Clock.System.now(), + updatedDate = null, + content = "Answer Post content $index", + authorRole = UserRole.USER, + authorName = "author name" + ), + reactions = emptyList(), + serverPostIdCache = AnswerPostPojo.ServerPostIdCache( + serverPostId = index.toLong() + ) + ) + } + + private val post = PostPojo( + clientPostId = "client-id", + serverPostId = 12, + content = "Post content", + resolved = false, + updatedDate = null, + creationDate = Clock.System.now(), + authorId = clientId, + title = null, + authorName = "author name", + authorRole = UserRole.USER, + courseWideContext = null, + tags = emptyList(), + answers = answers, + reactions = emptyList() + ) + + private fun testTagForAnswerPost(answerPostId: String) = "answerPost$answerPostId" + + @Test + fun `test GIVEN post is not resolved WHEN resolving the post THEN the post is resolved with the first answer post`() { + var resolvedPost: IBasePost? = null + + setupUi(post) { post -> + resolvedPost = post + CompletableDeferred() + } + + composeTestRule.onNodeWithText(answers[0].content!!, useUnmergedTree = true) + .performSemanticsAction(SemanticsActions.OnLongClick) + composeTestRule.onNodeWithText(context.getString(R.string.post_resolves)) + .performClick() + + assert(resolvedPost != null) + assert(resolvedPost is AnswerPostPojo) + assert((resolvedPost as AnswerPostPojo).content == answers[0].content!!) + } + + @Test + fun `test GIVEN post is not resolved WHEN resolving the post THEN the post is resolved with the third answer post`() { + var resolvedPost: IBasePost? = null + + setupUi(post) { post -> + resolvedPost = post + CompletableDeferred() + } + + composeTestRule.onNodeWithText(answers[2].content!!, useUnmergedTree = true) + .performSemanticsAction(SemanticsActions.OnLongClick) + composeTestRule.onNodeWithText(context.getString(R.string.post_resolves)).performClick() + + assert(resolvedPost != null) + assert(resolvedPost is AnswerPostPojo) + assert((resolvedPost as AnswerPostPojo).content == answers[2].content!!) + } + + @Test + fun `test GIVEN post is resolved WHEN un-resolving the post THEN the post is un-resolved`() { + val resolvingIndex = 0 + + val modifiedAnswers = answers.toMutableList() + modifiedAnswers[resolvingIndex] = modifiedAnswers[resolvingIndex].copy(resolvesPost = true) + val resolvedPost = post.copy( + resolved = true, + answers = modifiedAnswers + ) + + var unresolvedPost: IBasePost? = null + + setupUi(resolvedPost) { post -> + unresolvedPost = post + CompletableDeferred() + } + + composeTestRule.onNodeWithText(answers[resolvingIndex].content!!, useUnmergedTree = true) + .performSemanticsAction(SemanticsActions.OnLongClick) + composeTestRule.onNodeWithText(context.getString(R.string.post_does_not_resolve)) + .performClick() + + assert(unresolvedPost != null) + assert(unresolvedPost is AnswerPostPojo) + assert((unresolvedPost as AnswerPostPojo).content == answers[resolvingIndex].content!!) + } + + @Test + fun `test GIVEN the post is not resolved and no answer post is resolving THEN the post is shown as not resolved and no answer post is shown as resolving`() { + setupUi(post) { CompletableDeferred() } + + composeTestRule.onNodeWithText(post.content).assertExists() + for (answer in answers) { + composeTestRule.onNodeWithText(answer.content!!).assertExists() + } + composeTestRule.onNodeWithText(context.getString(R.string.post_is_resolved)) + .assertDoesNotExist() + composeTestRule.onNodeWithText(context.getString(R.string.post_resolves)) + .assertDoesNotExist() + } + + @Test + fun `test GIVEN the post is resolved and one answer post is marked as resolving THEN the post is shown as resolved and this answer post is shown as resolving`() { + val resolvingIndex = 0 + + val modifiedAnswers = answers.toMutableList() + modifiedAnswers[resolvingIndex] = modifiedAnswers[resolvingIndex].copy(resolvesPost = true) + val resolvedPost = post.copy( + resolved = true, + answers = modifiedAnswers + ) + + setupUi(resolvedPost) { CompletableDeferred() } + + val resolvesAssertion = hasAnyChild(hasText(context.getString(R.string.post_resolves))) + + for (i in answers.indices) { + composeTestRule + .onNodeWithTag(TEST_TAG_THREAD_LIST, useUnmergedTree = true) + .performScrollToIndex(i) + + composeTestRule + .onNodeWithTag(testTagForAnswerPost(answers[i].postId), useUnmergedTree = true) + .assert(if (i == resolvingIndex) resolvesAssertion else resolvesAssertion.not()) + } + } + + private fun setupUi( + post: PostPojo, + onResolvePost: ((IBasePost) -> Deferred)? + ) { + composeTestRule.setContent { + MetisThreadUi( + modifier = Modifier.fillMaxSize(), + courseId = course.id!!, + clientId = clientId, + postDataState = DataState.Success(post), + conversationDataState = DataState.Success(conversation), + hasModerationRights = false, + isAtLeastTutorInCourse = false, + serverUrl = "", + emojiService = EmojiServiceStub, + initialReplyTextProvider = remember { TestInitialReplyTextProvider() }, + onCreatePost = { CompletableDeferred() }, + onEditPost = { _, _ -> CompletableDeferred() }, + onResolvePost = onResolvePost, + onDeletePost = { CompletableDeferred() }, + onRequestReactWithEmoji = { _, _, _ -> CompletableDeferred() }, + onRequestReload = {}, + onRequestRetrySend = { _, _ -> }, + imageLoaderCreation = { CompletableDeferred() } + ) + } + } +} \ No newline at end of file diff --git a/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ConversationAutoCompletionE2eTest.kt b/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ConversationAutoCompletionE2eTest.kt new file mode 100644 index 000000000..d1f9e1405 --- /dev/null +++ b/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ConversationAutoCompletionE2eTest.kt @@ -0,0 +1,57 @@ +package de.tum.informatics.www1.artemis.native_app.feature.metis.conversation + +import de.tum.informatics.www1.artemis.native_app.core.common.test.EndToEndTest +import de.tum.informatics.www1.artemis.native_app.core.common.test.DefaultTestTimeoutMillis +import de.tum.informatics.www1.artemis.native_app.core.test.test_setup.DefaultTimeoutMillis +import de.tum.informatics.www1.artemis.native_app.core.common.test.testServerUrl +import de.tum.informatics.www1.artemis.native_app.core.model.account.User +import de.tum.informatics.www1.artemis.native_app.feature.login.test.user1Username +import de.tum.informatics.www1.artemis.native_app.feature.login.test.user2Username +import de.tum.informatics.www1.artemis.native_app.feature.login.test.user3Username +import de.tum.informatics.www1.artemis.native_app.feature.metistest.ConversationBaseTest +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.experimental.categories.Category +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.util.Logger +import kotlin.test.assertTrue +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.milliseconds + + +@Category(EndToEndTest::class) +@RunWith(RobolectricTestRunner::class) +class ConversationAutoCompletionE2eTest : ConversationBaseTest() { + + @Test(timeout = DefaultTestTimeoutMillis) + fun `test GIVEN users are registered in a course WHEN requesting auto complete users THEN the registered users are returned`() { + val users = listOf( + User(username = user1Username), + User(username = user2Username), + User(username = user3Username) + ) + + runTest(timeout = DefaultTimeoutMillis.milliseconds) { + + val typedText = "user" + val autoCompleteSuggestions = conversationService.searchForCourseMembers( + courseId = course.id!!, + query = typedText, + authToken = accessToken, + serverUrl = testServerUrl + ).orThrow("Could not get auto-complete suggestions") + + Logger.info("Auto-complete suggestions: $autoCompleteSuggestions") + + assertEquals(users.size, autoCompleteSuggestions.size) + + users.forEach { user -> + assertTrue( + autoCompleteSuggestions.any { it.username == user.username }, + "Auto-complete suggestions do not contain user $user" + ) + } + } + } +} \ No newline at end of file diff --git a/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/TestInitialReplyTextProvider.kt b/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/TestInitialReplyTextProvider.kt new file mode 100644 index 000000000..ebeb5f7f3 --- /dev/null +++ b/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/TestInitialReplyTextProvider.kt @@ -0,0 +1,14 @@ +package de.tum.informatics.www1.artemis.native_app.feature.metis.conversation + +import androidx.compose.ui.text.input.TextFieldValue +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.reply.InitialReplyTextProvider +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +class TestInitialReplyTextProvider( + override val newMessageText: Flow = flowOf( + TextFieldValue("") + ) +) : InitialReplyTextProvider { + override fun updateInitialReplyText(text: TextFieldValue) = Unit +} diff --git a/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/chatlist/MetisSearchPagingSourceTest.kt b/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/chatlist/MetisSearchPagingSourceTest.kt new file mode 100644 index 000000000..286369894 --- /dev/null +++ b/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/chatlist/MetisSearchPagingSourceTest.kt @@ -0,0 +1,54 @@ +package de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.chatlist + +import androidx.paging.PagingSource +import de.tum.informatics.www1.artemis.native_app.core.common.test.UnitTest +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.network.MetisService +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.MetisContext +import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.StandalonePost +import de.tum.informatics.www1.artemis.native_app.feature.metistest.MetisServiceStub +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.experimental.categories.Category +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + + +@Category(UnitTest::class) +@RunWith(RobolectricTestRunner::class) +class MetisSearchPagingSourceTest { + + private val metisServiceStub = MetisServiceStub() + private val sut: MetisSearchPagingSource = MetisSearchPagingSource( + metisService = metisServiceStub, + context = MetisService.StandalonePostsContext( + metisContext = MetisContext.Course(1), + filter = emptyList(), + query = null + ), + authToken = "token", + serverUrl = "url" + ) + + @Test + fun `test GIVEN the metisService returns duplicated posts WHEN calling the load method THEN only unique posts are returned`() = runTest { + // GIVEN + val post1 = StandalonePost(id = 1) + val post2 = StandalonePost(id = 2) + + val posts = listOf(post1, post2, post1) + metisServiceStub.posts = posts + + // WHEN + val result = sut.load(PagingSource.LoadParams.Refresh(0, 10, false)) + + // THEN + assertTrue(result is PagingSource.LoadResult.Page) + val page = result as PagingSource.LoadResult.Page + assertEquals(2, page.data.size) + assertEquals(post1, page.data[0]) + assertEquals(post2, page.data[1]) + } + +} \ No newline at end of file diff --git a/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/ReplyTextFieldUiTest.kt b/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/ReplyTextFieldUiTest.kt new file mode 100644 index 000000000..93c4a6edc --- /dev/null +++ b/feature/metis/conversation/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/conversation/ui/reply/ReplyTextFieldUiTest.kt @@ -0,0 +1,146 @@ +package de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.reply + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.ComposeContentTestRule +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.text.input.TextFieldValue +import androidx.test.ext.junit.runners.AndroidJUnit4 +import de.tum.informatics.www1.artemis.native_app.core.data.DataState +import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.R +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ReplyTextFieldUiTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val autoCompleteHints = listOf( + AutoCompleteCategory( + R.string.markdown_textfield_autocomplete_category_users, listOf( + AutoCompleteHint("User1", "", "1"), + AutoCompleteHint("User2", "", "2"), + AutoCompleteHint("User3", "", "3"), + )) + ) + + private val hintProviderStub = object : ReplyAutoCompleteHintProvider { + override val legalTagChars: List = listOf('@') + override fun produceAutoCompleteHints(tagChar: Char, query: String): Flow>> { + return flowOf(DataState.Success(autoCompleteHints)) + } + } + + @Before + fun setUp() { + composeTestRule.setContent { + CompositionLocalProvider(LocalReplyAutoCompleteHintProvider provides hintProviderStub) { + val text = remember { mutableStateOf(TextFieldValue()) } + + ReplyTextField( + modifier = Modifier.fillMaxSize(), + replyMode = ReplyMode.NewMessage( + text, + onUpdateTextUpstream = { text.value = it } + ) { + CompletableDeferred() + }, + updateFailureState = {}, + title = "TestChat" + ) + } + } + + // Click the unfocused textField to focus and expand the textField + composeTestRule.onNodeWithTag(TEST_TAG_UNFOCUSED_TEXT_FIELD).performClick() + } + + @Test + fun `test GIVEN an empty reply textField WHEN doing nothing THEN the autoCompletion dialog is hidden`() { + composeTestRule.assertAllAutoCompletionHintsHidden() + } + + @Test + fun `test GIVEN an empty reply textField WHEN entering the tag character @ THEN a list of autoCompletionHints for users shows`() { + composeTestRule.onNodeWithTag(TEST_TAG_MARKDOWN_TEXTFIELD).performTextInput("@") + composeTestRule.assertAllAutoCompletionHintsShown() + } + + @Test + fun `test GIVEN the autoCompletion dialog WHEN clicking an entry THEN the replacement is inserted into the textField and the dialog is hidden`() { + composeTestRule.onNodeWithTag(TEST_TAG_MARKDOWN_TEXTFIELD).performTextInput("@") + + composeTestRule.onNodeWithText("User1").performClick() + + composeTestRule.onNodeWithTag(TEST_TAG_MARKDOWN_TEXTFIELD).assertTextEquals("") + composeTestRule.assertAllAutoCompletionHintsHidden() + } + + @Test + fun `test GIVEN the textField WHEN entering a non-tag character THEN the autoCompletion dialog is hidden`() { + composeTestRule.onNodeWithTag(TEST_TAG_MARKDOWN_TEXTFIELD).performTextInput("a") + composeTestRule.assertAllAutoCompletionHintsHidden() + } + + @Test + fun `test GIVEN the autoCompletion dialog WHEN removing the tag character @ THEN the autoCompletion dialog is hidden`() { + composeTestRule.onNodeWithTag(TEST_TAG_MARKDOWN_TEXTFIELD).performTextInput("@") + composeTestRule.assertAllAutoCompletionHintsShown() + + composeTestRule.onNodeWithTag(TEST_TAG_MARKDOWN_TEXTFIELD).performTextClearance() + composeTestRule.assertAllAutoCompletionHintsHidden() + } + + @Test + fun `test GIVEN the autoCompletion has been performed WHEN entering the tag character again THEN the autoCompletion dialog shows again`() { + composeTestRule.onNodeWithTag(TEST_TAG_MARKDOWN_TEXTFIELD).performTextInput("@") + composeTestRule.onNodeWithText("User1").performClick() + composeTestRule.onNodeWithTag(TEST_TAG_MARKDOWN_TEXTFIELD).assertTextEquals("") + composeTestRule.assertAllAutoCompletionHintsHidden() + + composeTestRule.onNodeWithTag(TEST_TAG_MARKDOWN_TEXTFIELD).performTextInput("@") + composeTestRule.assertAllAutoCompletionHintsShown() + } + + @Test + fun `test GIVEN the textField WHEN entering a first and surname separated by a single whitespace THEN the dialog shows`() { + composeTestRule.onNodeWithTag(TEST_TAG_MARKDOWN_TEXTFIELD).performTextInput("@FirstName SurName") + composeTestRule.assertAllAutoCompletionHintsShown() + } + + @Test + fun `test GIVEN the textField WHEN entering a second whitespace THEN the dialog is hidden`() { + composeTestRule.onNodeWithTag(TEST_TAG_MARKDOWN_TEXTFIELD).performTextInput("@FirstName SurName ") + composeTestRule.assertAllAutoCompletionHintsHidden() + } + + + + private fun ComposeContentTestRule.assertAllAutoCompletionHintsHidden() { + onNodeWithText("User1").assertDoesNotExist() + onNodeWithText("User2").assertDoesNotExist() + onNodeWithText("User3").assertDoesNotExist() + } + + private fun ComposeContentTestRule.assertAllAutoCompletionHintsShown() { + onNodeWithText("User1").assertExists() + onNodeWithText("User2").assertExists() + onNodeWithText("User3").assertExists() + } +} \ No newline at end of file diff --git a/feature/metis/shared/src/debug/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/ConversationServiceStub.kt b/feature/metis/shared/src/debug/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/ConversationServiceStub.kt index b4778efae..5963b39a8 100644 --- a/feature/metis/shared/src/debug/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/ConversationServiceStub.kt +++ b/feature/metis/shared/src/debug/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/ConversationServiceStub.kt @@ -34,6 +34,13 @@ open class ConversationServiceStub( serverUrl: String ): NetworkResponse> = NetworkResponse.Response(emptyList()) + override suspend fun searchForCourseMembers( + courseId: Long, + query: String, + authToken: String, + serverUrl: String + ): NetworkResponse> = NetworkResponse.Response(emptyList()) + override suspend fun createOneToOneConversation( courseId: Long, partner: String, diff --git a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/AnswerPost.kt b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/AnswerPost.kt index 9f2c006ff..4c4d216ee 100644 --- a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/AnswerPost.kt +++ b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/AnswerPost.kt @@ -16,7 +16,7 @@ data class AnswerPost( override val updatedDate: Instant? = null, override val content: String? = null, override val reactions: List? = null, - @SerialName("resolvedPost") + @SerialName("resolvesPost") override val resolvesPost: Boolean = false, val post: StandalonePost? = null ) : BasePost(), IAnswerPost { diff --git a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/conversation/Conversation.kt b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/conversation/Conversation.kt index b9d9596af..d510eac4f 100644 --- a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/conversation/Conversation.kt +++ b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/content/dto/conversation/Conversation.kt @@ -29,4 +29,4 @@ val Conversation.hasModerationRights: Boolean is ChannelChat -> isChannelModerator is GroupChat -> isCreator is OneToOneChat -> false - } + } \ No newline at end of file diff --git a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/service/network/ConversationService.kt b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/service/network/ConversationService.kt index f905a97ab..31e78cf2f 100644 --- a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/service/network/ConversationService.kt +++ b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/service/network/ConversationService.kt @@ -26,6 +26,13 @@ interface ConversationService { serverUrl: String ): NetworkResponse> + suspend fun searchForCourseMembers( + courseId: Long, + query: String, + authToken: String, + serverUrl: String + ): NetworkResponse> + suspend fun createOneToOneConversation( courseId: Long, partner: String, diff --git a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/service/network/impl/ConversationServiceImpl.kt b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/service/network/impl/ConversationServiceImpl.kt index 3cec1c73b..e5dfe1867 100644 --- a/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/service/network/impl/ConversationServiceImpl.kt +++ b/feature/metis/shared/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/metis/shared/service/network/impl/ConversationServiceImpl.kt @@ -72,6 +72,25 @@ class ConversationServiceImpl(private val ktorProvider: KtorProvider) : Conversa } } + override suspend fun searchForCourseMembers( + courseId: Long, + query: String, + authToken: String, + serverUrl: String + ): NetworkResponse> { + return performNetworkCall { + ktorProvider.ktorClient.get(serverUrl) { + url { + appendPathSegments("api", "courses", courseId.toString(), "members", "search") + + parameter("loginOrName", query) + } + + cookieAuth(authToken) + }.body() + } + } + override suspend fun createGroupChat( courseId: Long, groupMembers: List,