Skip to content

Commit

Permalink
Communication: Fix link rendering for attachments and show uploaded…
Browse files Browse the repository at this point in the history
… images in chat (#109)

Co-authored-by: Martin Felber <[email protected]>
  • Loading branch information
julian-wls and FelberMartin authored Nov 30, 2024
1 parent aafd80b commit 9b6c193
Show file tree
Hide file tree
Showing 16 changed files with 379 additions and 199 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,26 @@ abstract class ArtemisMarkdownTransformer {
channelName: String,
conversationId: Long
): String = ""

override fun transformLectureContentMarkdown(
type: String,
fileName: String,
url: String
) : String = ""

override fun transformFileUploadMessageMarkdown(
isImage: Boolean,
fileName: String,
filePath: String,
) : String = ""
}

private val exerciseMarkdownPattern =
"\\[(text|quiz|lecture|modeling|file-upload|programming)](.*)\\(((?:/|\\w|\\d)+)\\)\\[/\\1]".toRegex()
private val userMarkdownPattern = "\\[user](.*?)\\((.*?)\\)\\[/user]".toRegex()
private val channelMarkdownPattern = "\\[channel](.*?)\\((\\d+?)\\)\\[/channel]".toRegex()
private val lectureContentMarkdownPattern = "\\[(attachment|lecture-unit|slide)](.*?)\\(([/\\w\\d\\-_\\.]+)\\)\\[/\\1]".toRegex()
private val fileUploadMessagePattern = "(\\!?)\\[(.*?)]\\((/api/files/[\\w\\d/\\-_.]+)\\)".toRegex()

fun transformMarkdown(markdown: String): String {
return exerciseMarkdownPattern.replace(markdown) { matchResult ->
Expand All @@ -49,6 +63,30 @@ abstract class ArtemisMarkdownTransformer {
conversationId = conversationId
)
}
}.let {
lectureContentMarkdownPattern.replace(it) { matchResult ->
val type = matchResult.groups[1]?.value.orEmpty()
val fileName = matchResult.groups[2]?.value.orEmpty()
val url = matchResult.groups[3]?.value.orEmpty()
transformLectureContentMarkdown(
type = type,
fileName = fileName,
url = url
)
}
}.let {
fileUploadMessagePattern.replace(it) { matchResult ->
// file uploads can be images or other files represented by a link:
// image: ![fileName](url), file: [fileName](url)
val isImage = matchResult.groups[1]?.value.orEmpty() == "!"
val fileName = matchResult.groups[2]?.value.orEmpty()
val filePath = matchResult.groups[3]?.value.orEmpty()
transformFileUploadMessageMarkdown(
isImage = isImage,
fileName = fileName,
filePath = filePath
)
}
}
}

Expand All @@ -64,4 +102,16 @@ abstract class ArtemisMarkdownTransformer {
channelName: String,
conversationId: Long
): String

protected abstract fun transformLectureContentMarkdown(
type: String,
fileName: String,
url: String
): String

protected abstract fun transformFileUploadMessageMarkdown(
isImage: Boolean,
fileName: String,
filePath: String
): String
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,26 @@ class PostArtemisMarkdownTransformer(val serverUrl: String, val courseId: Long)
channelName: String,
conversationId: Long
): String = "[#$channelName](artemis://courses/$courseId/messages?conversationId=$conversationId)"

override fun transformLectureContentMarkdown(
type: String,
fileName: String,
url: String
): String {
return when (type) {
"attachment" -> "[$fileName](artemis:/$url)"
"lecture-unit" -> "[$fileName]($serverUrl/api/files/attachments/$url)" // TODO: fix authentication or redirect to lecture unit (https://github.com/ls1intum/artemis-android/issues/117)
"slide" -> "[$fileName]($serverUrl/api/files/attachments/$url)" // TODO: fix authentication or redirect to lecture unit (https://github.com/ls1intum/artemis-android/issues/117)
else -> fileName
}
}

override fun transformFileUploadMessageMarkdown(
isImage: Boolean,
fileName: String,
filePath: String
): String {
// TODO: fix authentication or redirect for all non-image uploads (https://github.com/ls1intum/artemis-android/issues/117)
return if (isImage) "![$fileName]($serverUrl$filePath)" else "[$fileName]($serverUrl$filePath)"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,8 @@ object PushNotificationArtemisMarkdownTransformer : ArtemisMarkdownTransformer()
channelName: String,
conversationId: Long
): String = "#$channelName"

override fun transformLectureContentMarkdown(type: String, fileName: String, url: String): String = fileName

override fun transformFileUploadMessageMarkdown(isImage: Boolean, fileName: String, filePath: String) = fileName
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ import androidx.compose.ui.unit.sp
import androidx.core.graphics.toColorInt
import de.tum.informatics.www1.artemis.native_app.core.model.Course
import de.tum.informatics.www1.artemis.native_app.core.model.CourseWithScore
import de.tum.informatics.www1.artemis.native_app.core.ui.LocalCourseImageProvider
import de.tum.informatics.www1.artemis.native_app.core.ui.remote_images.LocalCourseImageProvider
import de.tum.informatics.www1.artemis.native_app.core.ui.R
import de.tum.informatics.www1.artemis.native_app.core.ui.common.AutoResizeText
import de.tum.informatics.www1.artemis.native_app.core.ui.common.FontSizeRange
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ val LocalMarkwon: ProvidableCompositionLocal<Markwon?> =
fun ProvideMarkwon(imageLoader: ImageLoader? = null, content: @Composable () -> Unit) {
val context = LocalContext.current

val imageWith = context.resources.displayMetrics.widthPixels
val markdownRender: Markwon = remember(imageLoader) {
createMarkdownRender(context, imageLoader)
createMarkdownRender(context, imageLoader, imageWith)
}

CompositionLocalProvider(LocalMarkwon provides markdownRender) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ 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.util.TypedValue
import android.view.View
import android.widget.TextView
Expand Down Expand Up @@ -31,11 +30,15 @@ import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.res.ResourcesCompat
import coil.ImageLoader
import coil.request.Disposable
import coil.request.ImageRequest
import coil.size.Scale
import de.tum.informatics.www1.artemis.native_app.core.common.markdown.ArtemisMarkdownTransformer
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.AsyncDrawable
import io.noties.markwon.image.coil.CoilImagesPlugin
import io.noties.markwon.linkify.LinkifyPlugin

Expand Down Expand Up @@ -87,8 +90,9 @@ fun MarkdownText(
val context: Context = LocalContext.current
val localMarkwon = LocalMarkwon.current

val imageWith = context.resources.displayMetrics.widthPixels
val markdownRender: Markwon = localMarkwon ?: remember(imageLoader) {
createMarkdownRender(context, imageLoader)
createMarkdownRender(context, imageLoader, imageWith)
}

val markdownTransformer = LocalMarkdownTransformer.current
Expand All @@ -101,11 +105,12 @@ fun MarkdownText(

AndroidView(
// Added semantics for ui testing.
modifier = modifier.semantics {
text = AnnotatedString(markdown)
onClick?.let { this.onClick(action = { onClick(); true }) }
onLongClick?.let { this.onLongClick(action = { onLongClick(); true }) }
},
modifier = modifier
.semantics {
text = AnnotatedString(markdown)
onClick?.let { this.onClick(action = { onClick(); true }) }
onLongClick?.let { this.onLongClick(action = { onLongClick(); true }) }
},
factory = { ctx ->
createTextView(
context = ctx,
Expand Down Expand Up @@ -204,16 +209,39 @@ private fun TextView.applyStyleAndColor(
}
}

fun createMarkdownRender(context: Context, imageLoader: ImageLoader?): Markwon {
fun createMarkdownRender(context: Context, imageLoader: ImageLoader?, imageWith: Int): Markwon {
// Setting the size of the output image is important to avoid jittering UIs.
val imagePlugin: CoilImagesPlugin? =
if (imageLoader != null) {
CoilImagesPlugin.create(
object : CoilImagesPlugin.CoilStore {
override fun load(drawable: AsyncDrawable): ImageRequest {
return ImageRequest.Builder(context)
.defaults(imageLoader.defaults)
.data(drawable.destination)
.crossfade(true)
.size(imageWith, 800) // We set a fixed height and set the width of the image to the screen width.
.scale(Scale.FIT)
.build()
}

override fun cancel(disposable: Disposable) {
disposable.dispose()
}
},
imageLoader
)
} else null

return Markwon.builder(context)
.usePlugin(HtmlPlugin.create())
.usePlugin(StrikethroughPlugin.create())
.usePlugin(TablePlugin.create(context))
.usePlugin(LinkifyPlugin.create())
.apply {
if (imageLoader != null) {
usePlugin(CoilImagesPlugin.create(context, imageLoader))
if (imagePlugin != null) {
usePlugin(imagePlugin)
}
}
.build()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package de.tum.informatics.www1.artemis.native_app.core.ui.remote_images

import android.content.Context
import coil.ImageLoader
import coil.request.ImageRequest

interface BaseImageProvider {
fun createImageRequest(
context: Context,
imagePath: String,
serverUrl: String,
authorizationToken: String,
memoryCacheKey: String? = null
): ImageRequest

fun createImageLoader(context: Context, authorizationToken: String): ImageLoader
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package de.tum.informatics.www1.artemis.native_app.core.ui.remote_images

import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalContext
import coil.compose.rememberAsyncImagePainter

val LocalCourseImageProvider = compositionLocalOf<CourseImageProvider> { DefaultCourseImageProvider }

interface CourseImageProvider {
@Composable
fun rememberCourseImagePainter(
courseIconPath: String,
serverUrl: String,
authorizationToken: String
): Painter
}

private object DefaultCourseImageProvider : CourseImageProvider {
private val imageProvider = DefaultImageProvider()

@Composable
override fun rememberCourseImagePainter(
courseIconPath: String,
serverUrl: String,
authorizationToken: String
): Painter {
val context = LocalContext.current
val imageRequest = remember {
imageProvider.createImageRequest(context, courseIconPath, serverUrl, authorizationToken)
}
return rememberAsyncImagePainter(model = imageRequest)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package de.tum.informatics.www1.artemis.native_app.core.ui.remote_images

import android.content.Context
import coil.ImageLoader
import coil.request.ImageRequest
import io.ktor.http.HttpHeaders
import io.ktor.http.URLBuilder
import io.ktor.http.appendPathSegments

class DefaultImageProvider : BaseImageProvider {
override fun createImageRequest(
context: Context,
imagePath: String,
serverUrl: String,
authorizationToken: String,
memoryCacheKey: String?
): ImageRequest {
val imageUrl = URLBuilder(serverUrl).appendPathSegments(imagePath).buildString()

val builder = ImageRequest.Builder(context)
.addHeader(HttpHeaders.Cookie, "jwt=$authorizationToken")
.data(imageUrl)

memoryCacheKey?.let {
builder.memoryCacheKey(it)
}
return builder.build()
}

override fun createImageLoader(
context: Context,
authorizationToken: String
): ImageLoader {
return ImageLoader.Builder(context)
.okHttpClient {
okhttp3.OkHttpClient.Builder()
.addInterceptor { chain ->
val request = chain.request().newBuilder()
.addHeader(HttpHeaders.Cookie, "jwt=$authorizationToken")
.build()
chain.proceed(request)
}
.build()
}
.build()
}
}
Loading

0 comments on commit 9b6c193

Please sign in to comment.