Skip to content

Commit

Permalink
Feature: support user mentions (#10)
Browse files Browse the repository at this point in the history
* Support displaying user mentions in posts and push notifications.

* Use simple ext plugin instead.

* Support user autocomplete on smartphones.

* Automatic popup position and height restriction. Ondismiss request.

* Only show non-empty user autocompletion popups.

* Fix merge problems.
  • Loading branch information
TimOrtel authored Nov 15, 2023
1 parent eeacf87 commit 3b76d60
Show file tree
Hide file tree
Showing 33 changed files with 643 additions and 104 deletions.
Original file line number Diff line number Diff line change
@@ -1,14 +1,34 @@
package de.tum.informatics.www1.artemis.native_app.core.common.markdown

object ArtemisMarkdownTransformer {
abstract class ArtemisMarkdownTransformer {

private val customMarkdownPattern = "\\[(text|quiz|lecture|modeling|file-upload|programing)](.*)\\(((?:/|\\w|\\d)+)\\)\\[/\\1]".toRegex()
private val exerciseMarkdownPattern =
"\\[(text|quiz|lecture|modeling|file-upload|programing)](.*)\\(((?:/|\\w|\\d)+)\\)\\[/\\1]".toRegex()
private val userMarkdownPattern = "\\[user](.*?)\\((.*?)\\)\\[/user]".toRegex()

fun transformMarkdown(markdown: String, serverUrl: String): String {
return customMarkdownPattern.replace(markdown) { matchResult ->
fun transformMarkdown(markdown: String): String {
return exerciseMarkdownPattern.replace(markdown) { matchResult ->
val title = matchResult.groups[2]?.value.orEmpty()
val url = matchResult.groups[3]?.value.orEmpty()
"[$title]($serverUrl$url)"
transformExerciseMarkdown(title, url)
}.let {
userMarkdownPattern.replace(it) { matchResult ->
val fullName = matchResult.groups[1]?.value.orEmpty()
val userName = matchResult.groups[2]?.value.orEmpty()
transformUserMentionMarkdown(
text = matchResult.groups[0]?.value.orEmpty(),
fullName = fullName,
userName = userName
)
}
}
}

protected abstract fun transformExerciseMarkdown(title: String, url: String): String

protected abstract fun transformUserMentionMarkdown(
text: String,
fullName: String,
userName: String
): String
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package de.tum.informatics.www1.artemis.native_app.core.common.markdown

class PostArtemisMarkdownTransformer(val serverUrl: String) : ArtemisMarkdownTransformer() {
override fun transformExerciseMarkdown(title: String, url: String): String {
return "[$title]($serverUrl$url)"
}

override fun transformUserMentionMarkdown(text: String, fullName: String, userName: String): String = "|||@$fullName|||"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package de.tum.informatics.www1.artemis.native_app.core.common.markdown

object PushNotificationArtemisMarkdownTransformer : ArtemisMarkdownTransformer() {

override fun transformExerciseMarkdown(title: String, url: String): String = title

override fun transformUserMentionMarkdown(text: String, fullName: String, userName: String): String = "@$fullName"
}
1 change: 1 addition & 0 deletions core/ui/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ dependencies {
implementation(libs.noties.markwon.ext.strikethrough)
implementation(libs.noties.markwon.ext.tables)
implementation(libs.noties.markwon.html)
implementation(libs.noties.markwon.simple.ext)
implementation(libs.noties.markwon.linkify)
implementation(libs.noties.markwon.image.coil)
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package de.tum.informatics.www1.artemis.native_app.core.ui.markdown

import android.content.Context
import android.text.method.LinkMovementMethod
import android.text.style.ForegroundColorSpan
import android.text.style.StrikethroughSpan
import android.util.TypedValue
import android.view.View
import android.widget.TextView
Expand Down Expand Up @@ -31,14 +33,15 @@ import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.res.ResourcesCompat
import coil.ImageLoader
import de.tum.informatics.www1.artemis.native_app.core.common.markdown.ArtemisMarkdownTransformer
import de.tum.informatics.www1.artemis.native_app.core.common.markdown.PostArtemisMarkdownTransformer
import de.tum.informatics.www1.artemis.native_app.core.datastore.ServerConfigurationService
import io.noties.markwon.Markwon
import io.noties.markwon.ext.strikethrough.StrikethroughPlugin
import io.noties.markwon.ext.tables.TablePlugin
import io.noties.markwon.html.HtmlPlugin
import io.noties.markwon.image.coil.CoilImagesPlugin
import io.noties.markwon.linkify.LinkifyPlugin
import io.noties.markwon.simple.ext.SimpleExtPlugin
import org.koin.compose.koinInject

// Copy from: https://github.com/jeziellago/compose-markdown
Expand Down Expand Up @@ -100,15 +103,19 @@ fun MarkdownText(
serverConfigurationService.serverUrl.collectAsState(initial = "").value
}

val transformedMarkdown by remember(markdown, serverUrl) {
derivedStateOf {
val strippedServerUrl =
if (serverUrl.endsWith("/")) serverUrl.substring(
0,
serverUrl.length - 1
) else serverUrl
val markdownTransformer = remember(serverUrl) {
val strippedServerUrl =
if (serverUrl.endsWith("/")) serverUrl.substring(
0,
serverUrl.length - 1
) else serverUrl

PostArtemisMarkdownTransformer(strippedServerUrl)
}

ArtemisMarkdownTransformer.transformMarkdown(markdown, strippedServerUrl)
val transformedMarkdown by remember(markdown, markdownTransformer) {
derivedStateOf {
markdownTransformer.transformMarkdown(markdown)
}
}

Expand Down Expand Up @@ -200,10 +207,16 @@ fun createMarkdownRender(context: Context, imageLoader: ImageLoader?): Markwon {
.usePlugin(StrikethroughPlugin.create())
.usePlugin(TablePlugin.create(context))
.usePlugin(LinkifyPlugin.create())
// User mentions are transformed to |||@full name|||
.usePlugin(SimpleExtPlugin.create { p ->
p.addExtension(3, '|') { _, _ ->
arrayOf(ForegroundColorSpan(0xff3e8acc.toInt()))
}
})
.apply {
if (imageLoader != null) {
usePlugin(CoilImagesPlugin.create(context, imageLoader))
}
}
.build()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -258,9 +258,7 @@ internal fun CourseUiScreen(
}

TAB_COMMUNICATION -> {
val metisModifier = Modifier
.fillMaxSize()
.padding(horizontal = 8.dp)
val metisModifier = Modifier.fillMaxSize()

if (course.courseInformationSharingConfiguration.supportsMessaging) {
val initialConfiguration = remember(conversationId, postId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
Expand All @@ -31,6 +32,7 @@ import de.tum.informatics.www1.artemis.native_app.core.ui.common.BasicHintTextFi
import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.MetisContext
import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.chatlist.MetisChatList
import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.chatlist.MetisListViewModel
import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.reply.LocalReplyAutoCompleteHintProvider
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.ui.humanReadableName
import io.github.fornewid.placeholder.material3.placeholder
Expand Down Expand Up @@ -134,15 +136,17 @@ fun ConversationScreen(
val isReplyEnabled = isReplyEnabled(conversationDataState = conversationDataState)

if (conversationDataState.isSuccess) {
MetisChatList(
modifier = Modifier
.fillMaxSize()
.padding(padding),
viewModel = viewModel,
listContentPadding = PaddingValues(),
onClickViewPost = onClickViewPost,
isReplyEnabled = isReplyEnabled
)
CompositionLocalProvider(LocalReplyAutoCompleteHintProvider provides viewModel) {
MetisChatList(
modifier = Modifier
.fillMaxSize()
.padding(padding),
viewModel = viewModel,
listContentPadding = PaddingValues(),
onClickViewPost = onClickViewPost,
isReplyEnabled = isReplyEnabled
)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ internal class MetisListViewModel(

fun createPost(): Deferred<MetisModificationFailure?> {
return viewModelScope.async(coroutineContext) {
val postText = newMessageText.first()
val postText = newMessageText.first().text

val conversation =
loadConversation() ?: return@async MetisModificationFailure.CREATE_POST
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.reply

import androidx.annotation.StringRes

class AutoCompleteCategory(@StringRes val name: Int, val items: List<AutoCompleteHint>)
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.reply

data class AutoCompleteHint(val hint: String, val replacementText: String, val id: String)
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
package de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.reply

import androidx.compose.ui.text.input.TextFieldValue
import kotlinx.coroutines.flow.Flow

interface InitialReplyTextProvider {

val newMessageText: Flow<String>
val newMessageText: Flow<TextFieldValue>

fun updateInitialReplyText(text: String)
fun updateInitialReplyText(text: TextFieldValue)
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.InputChip
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
Expand All @@ -22,6 +23,8 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
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
Expand All @@ -32,13 +35,16 @@ import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.R
@Composable
internal fun MarkdownTextField(
modifier: Modifier,
text: String,
textFieldValue: TextFieldValue,
focusRequester: FocusRequester = remember { FocusRequester() },
sendButton: @Composable () -> Unit = {},
topRightButton: @Composable RowScope.() -> Unit = {},
onFocusAcquired: () -> Unit = {},
onFocusLost: () -> Unit = {},
onTextChanged: (String) -> Unit
onTextChanged: (TextFieldValue) -> Unit
) {
val text = textFieldValue.text

var selectedType by remember { mutableStateOf(ViewType.TEXT) }
var hadFocus by remember { mutableStateOf(false) }

Expand Down Expand Up @@ -81,15 +87,17 @@ internal fun MarkdownTextField(
.onFocusChanged { focusState ->
if (focusState.hasFocus) {
hadFocus = true
onFocusAcquired()
}

if (!focusState.hasFocus && hadFocus) {
onFocusLost()
hadFocus = false
}
},
value = text,
onValueChange = onTextChanged
value = textFieldValue,
onValueChange = onTextChanged,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text)
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.reply

import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.compositionLocalOf
import de.tum.informatics.www1.artemis.native_app.core.data.DataState
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf

internal val LocalReplyAutoCompleteHintProvider: ProvidableCompositionLocal<ReplyAutoCompleteHintProvider> = compositionLocalOf {
object : ReplyAutoCompleteHintProvider {
override val legalTagChars: List<Char> = emptyList()

override fun produceAutoCompleteHints(
tagChar: Char,
query: String
): Flow<DataState<List<AutoCompleteCategory>>> = flowOf(DataState.Success(emptyList()))
}
}

internal interface ReplyAutoCompleteHintProvider {

val legalTagChars: List<Char>

fun produceAutoCompleteHints(
tagChar: Char,
query: String
): Flow<DataState<List<AutoCompleteCategory>>>
}
Loading

0 comments on commit 3b76d60

Please sign in to comment.