From b9be125c9540c5bce1f4b21943c1edff029060b4 Mon Sep 17 00:00:00 2001
From: Nik Clayton
Date: Fri, 10 Mar 2023 20:30:55 +0100
Subject: [PATCH] Show the difference between edited statuses (#3314)
* Show the difference between edited statuses
Diff each status against the previous version, comparing the different
HTML as XML to produce a structured diff.
Mark new content with ``, deleted content with ``.
Convert these to styled spans in `ViewEditsAdapter`.
* Update diffx to 1.1.1
Fixes issue with diffs splitting on accented characters
* Style edited strings with Android spans
Don't use HTML spans and try and format them, create real Android spans.
Do this with a custom tag handler that can add custom spans that set the
text paint appropriately.
* Lint
* Move colors in to theme_colors.xml
* Draw a roundrect for the backoround, add start/end padding
Make the background slightlysofter by drawing it as a roundrect.
Make the spans easier to understand by padding the start/end of each one with
the width of a " " character. This is visual only, the underlying text is not
changed.
* Catch exceptions when parsing XML
* Move sorting in to Dispatchers.Default coroutine
* Scope the loader type
* Remove alpha
---
app/build.gradle | 2 +
.../viewthread/edits/ViewEditsAdapter.kt | 193 +++++++++++++++++-
.../viewthread/edits/ViewEditsViewModel.kt | 144 ++++++++++++-
.../tusky/util/StatusParsingHelper.kt | 6 +-
.../main/res/values-night/theme_colors.xml | 3 +
app/src/main/res/values/dimens.xml | 2 +
app/src/main/res/values/theme_colors.xml | 3 +
gradle/libs.versions.toml | 5 +
8 files changed, 349 insertions(+), 9 deletions(-)
diff --git a/app/build.gradle b/app/build.gradle
index 2ccfcebf2..685c6dbca 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -160,6 +160,8 @@ dependencies {
implementation libs.bouncycastle
implementation libs.unified.push
+ implementation libs.bundles.xmldiff
+
testImplementation libs.androidx.test.junit
testImplementation libs.robolectric
testImplementation libs.bundles.mockito
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsAdapter.kt
index 88396bceb..075a84e23 100644
--- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsAdapter.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsAdapter.kt
@@ -1,7 +1,17 @@
package com.keylesspalace.tusky.components.viewthread.edits
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.graphics.Typeface.DEFAULT_BOLD
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
+import android.text.Editable
+import android.text.Html
+import android.text.Spannable
+import android.text.SpannableStringBuilder
+import android.text.Spanned
+import android.text.style.ReplacementSpan
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@@ -30,6 +40,7 @@ import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.unicodeWrap
import com.keylesspalace.tusky.util.visible
import com.keylesspalace.tusky.viewdata.toViewData
+import org.xml.sax.XMLReader
class ViewEditsAdapter(
private val edits: List,
@@ -47,11 +58,11 @@ class ViewEditsAdapter(
): BindingHolder {
val binding = ItemStatusEditBinding.inflate(LayoutInflater.from(parent.context), parent, false)
binding.statusEditMediaPreview.clipToOutline = true
+
return BindingHolder(binding)
}
override fun onBindViewHolder(holder: BindingHolder, position: Int) {
-
val edit = edits[position]
val binding = holder.binding
@@ -90,7 +101,11 @@ class ViewEditsAdapter(
)
}
- val emojifiedText = edit.content.parseAsMastodonHtml().emojify(edit.emojis, binding.statusEditContent, animateEmojis)
+ val emojifiedText = edit
+ .content
+ .parseAsMastodonHtml(TuskyTagHandler(context))
+ .emojify(edit.emojis, binding.statusEditContent, animateEmojis)
+
setClickableText(binding.statusEditContent, emojifiedText, emptyList(), emptyList(), listener)
if (edit.poll == null) {
@@ -184,3 +199,177 @@ class ViewEditsAdapter(
override fun getItemCount() = edits.size
}
+
+/**
+ * Handle XML tags created by [ViewEditsViewModel] and create custom spans to display inserted or
+ * deleted text.
+ */
+class TuskyTagHandler(val context: Context) : Html.TagHandler {
+ /** Class to mark the start of a span of deleted text */
+ class Del
+
+ /** Class to mark the start of a span of inserted text */
+ class Ins
+
+ override fun handleTag(opening: Boolean, tag: String, output: Editable, xmlReader: XMLReader) {
+ when (tag) {
+ DELETED_TEXT_EL -> {
+ if (opening) {
+ start(output as SpannableStringBuilder, Del())
+ } else {
+ end(
+ output as SpannableStringBuilder,
+ Del::class.java,
+ DeletedTextSpan(context)
+ )
+ }
+ }
+ INSERTED_TEXT_EL -> {
+ if (opening) {
+ start(output as SpannableStringBuilder, Ins())
+ } else {
+ end(
+ output as SpannableStringBuilder,
+ Ins::class.java,
+ InsertedTextSpan(context)
+ )
+ }
+ }
+ }
+ }
+
+ /** @return the last span in [text] of type [kind], or null if that kind is not in text */
+ private fun getLast(text: Spanned, kind: Class): Any? {
+ val spans = text.getSpans(0, text.length, kind)
+ return spans?.get(spans.size - 1)
+ }
+
+ /**
+ * Mark the start of a span of [text] with [mark] so it can be discovered later by [end].
+ */
+ private fun start(text: SpannableStringBuilder, mark: Any) {
+ val len = text.length
+ text.setSpan(mark, len, len, Spannable.SPAN_MARK_MARK)
+ }
+
+ /**
+ * Set a [span] over the [text] most from the point recently marked with [mark] to the end
+ * of the text.
+ */
+ private fun end(text: SpannableStringBuilder, mark: Class, span: Any) {
+ val len = text.length
+ val obj = getLast(text, mark)
+ val where = text.getSpanStart(obj)
+ text.removeSpan(obj)
+ if (where != len) {
+ text.setSpan(span, where, len, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
+ }
+ }
+
+ /**
+ * A span that draws text with additional padding at the start/end of the text. The padding
+ * is the width of [separator].
+ *
+ * Note: The separator string is not included in the final text, so it will not be included
+ * if the user cuts or copies the text.
+ */
+ open class LRPaddedSpan(val separator: String = " ") : ReplacementSpan() {
+ /** The width of the separator string, used as padding */
+ var paddingWidth = 0f
+
+ /** Measured width of the span */
+ var spanWidth = 0f
+
+ override fun getSize(
+ paint: Paint,
+ text: CharSequence?,
+ start: Int,
+ end: Int,
+ fm: Paint.FontMetricsInt?
+ ): Int {
+ paddingWidth = paint.measureText(separator, 0, separator.length)
+ spanWidth = (paddingWidth * 2) + paint.measureText(text, start, end)
+ return spanWidth.toInt()
+ }
+
+ override fun draw(
+ canvas: Canvas,
+ text: CharSequence?,
+ start: Int,
+ end: Int,
+ x: Float,
+ top: Int,
+ y: Int,
+ bottom: Int,
+ paint: Paint
+ ) {
+ canvas.drawText(text?.subSequence(start, end).toString(), x + paddingWidth, y.toFloat(), paint)
+ }
+ }
+
+ /** Span that signifies deleted text */
+ class DeletedTextSpan(context: Context) : LRPaddedSpan() {
+ private val bgPaint = Paint()
+ val radius: Float
+
+ init {
+ bgPaint.color = context.getColor(R.color.view_edits_background_delete)
+ radius = context.resources.getDimension(R.dimen.lrPaddedSpanRadius)
+ }
+
+ override fun draw(
+ canvas: Canvas,
+ text: CharSequence?,
+ start: Int,
+ end: Int,
+ x: Float,
+ top: Int,
+ y: Int,
+ bottom: Int,
+ paint: Paint
+ ) {
+ canvas.drawRoundRect(x, top.toFloat(), x + spanWidth, bottom.toFloat(), radius, radius, bgPaint)
+ paint.isStrikeThruText = true
+ super.draw(canvas, text, start, end, x, top, y, bottom, paint)
+ }
+ }
+
+ /** Span that signifies inserted text */
+ class InsertedTextSpan(context: Context) : LRPaddedSpan() {
+ val bgPaint = Paint()
+ val radius: Float
+
+ init {
+ bgPaint.color = context.getColor(R.color.view_edits_background_insert)
+ radius = context.resources.getDimension(R.dimen.lrPaddedSpanRadius)
+ }
+
+ override fun draw(
+ canvas: Canvas,
+ text: CharSequence?,
+ start: Int,
+ end: Int,
+ x: Float,
+ top: Int,
+ y: Int,
+ bottom: Int,
+ paint: Paint
+ ) {
+ canvas.drawRoundRect(x, top.toFloat(), x + spanWidth, bottom.toFloat(), radius, radius, bgPaint)
+ paint.typeface = DEFAULT_BOLD
+ super.draw(canvas, text, start, end, x, top, y, bottom, paint)
+ }
+ }
+
+ companion object {
+ /** XML element to represent text that has been deleted */
+ // Can't be an element that Android's HTML parser recognises, otherwise the tagHandler
+ // won't be called for it.
+ const val DELETED_TEXT_EL = "tusky-del"
+
+ /** XML element to represet text that has been inserted */
+ // Can't be an element that Android's HTML parser recognises, otherwise the tagHandler
+ // won't be called for it.
+ const val INSERTED_TEXT_EL = "tusky-ins"
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsViewModel.kt
index c5959d262..6dc0e25fc 100644
--- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsViewModel.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsViewModel.kt
@@ -18,17 +18,32 @@ package com.keylesspalace.tusky.components.viewthread.edits
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.connyduck.calladapter.networkresult.fold
+import com.keylesspalace.tusky.components.viewthread.edits.TuskyTagHandler.Companion.DELETED_TEXT_EL
+import com.keylesspalace.tusky.components.viewthread.edits.TuskyTagHandler.Companion.INSERTED_TEXT_EL
import com.keylesspalace.tusky.entity.StatusEdit
import com.keylesspalace.tusky.network.MastodonApi
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
+import org.pageseeder.diffx.api.LoadingException
+import org.pageseeder.diffx.api.Operator
+import org.pageseeder.diffx.config.DiffConfig
+import org.pageseeder.diffx.config.TextGranularity
+import org.pageseeder.diffx.config.WhiteSpaceProcessing
+import org.pageseeder.diffx.core.OptimisticXMLProcessor
+import org.pageseeder.diffx.format.XMLDiffOutput
+import org.pageseeder.diffx.load.SAXLoader
+import org.pageseeder.diffx.token.XMLToken
+import org.pageseeder.diffx.token.XMLTokenType
+import org.pageseeder.diffx.token.impl.SpaceToken
+import org.pageseeder.diffx.xml.NamespaceSet
+import org.pageseeder.xmlwriter.XML.NamespaceAware
+import org.pageseeder.xmlwriter.XMLStringWriter
import javax.inject.Inject
-class ViewEditsViewModel @Inject constructor(
- private val api: MastodonApi
-) : ViewModel() {
+class ViewEditsViewModel @Inject constructor(private val api: MastodonApi) : ViewModel() {
private val _uiState: MutableStateFlow = MutableStateFlow(EditsUiState.Initial)
val uiState: StateFlow = _uiState.asStateFlow()
@@ -45,8 +60,59 @@ class ViewEditsViewModel @Inject constructor(
viewModelScope.launch {
api.statusEdits(statusId).fold(
{ edits ->
- val sortedEdits = edits.sortedBy { edit -> edit.createdAt }.reversed()
- _uiState.value = EditsUiState.Success(sortedEdits)
+ // Diff each status' content against the previous version, producing new
+ // content with additional `ins` or `del` elements marking inserted or
+ // deleted content.
+ //
+ // This can be CPU intensive depending on the number of edits and the size
+ // of each, so don't run this on Dispatchers.Main.
+ viewModelScope.launch(Dispatchers.Default) {
+ val sortedEdits = edits.sortedBy { it.createdAt }
+ .reversed()
+ .toMutableList()
+
+ SAXLoader.setXMLReaderClass("org.xmlpull.v1.sax2.Driver")
+ val loader = SAXLoader()
+ loader.config = DiffConfig(
+ false,
+ WhiteSpaceProcessing.PRESERVE,
+ TextGranularity.SPACE_WORD
+ )
+ val processor = OptimisticXMLProcessor()
+ processor.setCoalesce(true)
+ val output = HtmlDiffOutput()
+
+ try {
+ // The XML processor expects `br` to be closed
+ var currentContent =
+ loader.load(sortedEdits[0].content.replace("
", "
"))
+ var previousContent =
+ loader.load(sortedEdits[1].content.replace("
", "
"))
+
+ for (i in 1 until sortedEdits.size) {
+ processor.diff(previousContent, currentContent, output)
+ sortedEdits[i - 1] = sortedEdits[i - 1].copy(
+ content = output.xml.toString()
+ )
+
+ if (i < sortedEdits.size - 1) {
+ currentContent = previousContent
+ previousContent = loader.load(
+ sortedEdits[i + 1].content.replace(
+ "
",
+ "
"
+ )
+ )
+ }
+ }
+ _uiState.value = EditsUiState.Success(sortedEdits)
+ } catch (_: LoadingException) {
+ // Something failed parsing the XML from the server. Rather than
+ // show an error just return the sorted edits so the user can at
+ // least visually scan the differences.
+ _uiState.value = EditsUiState.Success(sortedEdits)
+ }
+ }
},
{ throwable ->
_uiState.value = EditsUiState.Error(throwable)
@@ -54,6 +120,10 @@ class ViewEditsViewModel @Inject constructor(
)
}
}
+
+ companion object {
+ const val TAG = "ViewEditsViewModel"
+ }
}
sealed interface EditsUiState {
@@ -68,3 +138,67 @@ sealed interface EditsUiState {
val edits: List
) : EditsUiState
}
+
+/**
+ * Add elements wrapping inserted or deleted content.
+ */
+class HtmlDiffOutput : XMLDiffOutput {
+ /** XML Output */
+ lateinit var xml: XMLStringWriter
+ private set
+
+ override fun start() {
+ xml = XMLStringWriter(NamespaceAware.Yes)
+ }
+
+ override fun handle(operator: Operator, token: XMLToken) {
+ if (operator.isEdit) {
+ handleEdit(operator, token)
+ } else {
+ token.toXML(xml)
+ }
+ }
+
+ override fun end() {
+ xml.flush()
+ }
+
+ override fun setWriteXMLDeclaration(show: Boolean) {
+ // This space intentionally left blank
+ }
+
+ override fun setNamespaces(namespaces: NamespaceSet?) {
+ // This space intentionally left blank
+ }
+
+ private fun handleEdit(operator: Operator, token: XMLToken) {
+ if (token == SpaceToken.NEW_LINE) {
+ if (operator == Operator.INS) {
+ token.toXML(xml)
+ }
+ return
+ }
+ when (token.type) {
+ XMLTokenType.START_ELEMENT -> token.toXML(xml)
+ XMLTokenType.END_ELEMENT -> token.toXML(xml)
+ XMLTokenType.TEXT -> {
+ // wrap the characters in a element
+ when (operator) {
+ Operator.DEL -> DELETED_TEXT_EL
+ Operator.INS -> INSERTED_TEXT_EL
+ else -> null
+ }?.let {
+ xml.openElement(it, false)
+ }
+ token.toXML(xml)
+ xml.closeElement()
+ }
+ else -> {
+ // Only include inserted content
+ if (operator === Operator.INS) {
+ token.toXML(xml)
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/util/StatusParsingHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/StatusParsingHelper.kt
index 2ac4782c3..18c95c8ad 100644
--- a/app/src/main/java/com/keylesspalace/tusky/util/StatusParsingHelper.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/util/StatusParsingHelper.kt
@@ -16,6 +16,7 @@
@file:JvmName("StatusParsingHelper")
package com.keylesspalace.tusky.util
+import android.text.Html.TagHandler
import android.text.SpannableStringBuilder
import android.text.Spanned
import androidx.core.text.parseAsHtml
@@ -23,12 +24,13 @@ import androidx.core.text.parseAsHtml
/**
* parse a String containing html from the Mastodon api to Spanned
*/
-fun String.parseAsMastodonHtml(): Spanned {
+@JvmOverloads
+fun String.parseAsMastodonHtml(tagHandler: TagHandler? = null): Spanned {
return this.replace("
", "
")
.replace("
", "
")
.replace("
", "
")
.replace(" ", " ")
- .parseAsHtml()
+ .parseAsHtml(tagHandler = tagHandler)
/* Html.fromHtml returns trailing whitespace if the html ends in a
tag, which
* most status contents do, so it should be trimmed. */
.trimTrailingWhitespace()
diff --git a/app/src/main/res/values-night/theme_colors.xml b/app/src/main/res/values-night/theme_colors.xml
index 348652a72..cb57cf8d8 100644
--- a/app/src/main/res/values-night/theme_colors.xml
+++ b/app/src/main/res/values-night/theme_colors.xml
@@ -28,4 +28,7 @@
@color/white
@color/tusky_grey_10
+
+ #00731B
+ #DF0000
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
index b6f88d094..abba099e1 100644
--- a/app/src/main/res/values/dimens.xml
+++ b/app/src/main/res/values/dimens.xml
@@ -61,4 +61,6 @@
4dp
1dp
+
+ 4dp
diff --git a/app/src/main/res/values/theme_colors.xml b/app/src/main/res/values/theme_colors.xml
index 2f5da6b81..32f2727fd 100644
--- a/app/src/main/res/values/theme_colors.xml
+++ b/app/src/main/res/values/theme_colors.xml
@@ -28,4 +28,7 @@
@color/tusky_grey_20
@color/white
+
+ #CCFFD8
+ #FFC0C0
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index a81366661..72bb06678 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -24,6 +24,7 @@ bouncycastle = "1.70"
conscrypt = "2.5.2"
coroutines = "1.6.4"
dagger = "2.45"
+diffx = "1.1.1"
emoji2 = "1.2.0"
espresso = "3.5.1"
filemoji-compat = "3.2.7"
@@ -50,6 +51,7 @@ sparkbutton = "4.1.0"
truth = "1.1.3"
turbine = "0.12.1"
unified-push = "2.1.1"
+xmlwriter = "1.0.4"
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
@@ -99,6 +101,7 @@ dagger-android-processor = { module = "com.google.dagger:dagger-android-processo
dagger-android-support = { module = "com.google.dagger:dagger-android-support", version.ref = "dagger" }
dagger-compiler = { module = "com.google.dagger:dagger-compiler", version.ref = "dagger" }
dagger-core = { module = "com.google.dagger:dagger", version.ref = "dagger" }
+diffx = { module = "org.pageseeder.diffx:pso-diffx", version.ref = "diffx" }
espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" }
filemojicompat-core = { module = "de.c1710:filemojicompat", version.ref = "filemoji-compat" }
filemojicompat-defaults = { module = "de.c1710:filemojicompat-defaults", version.ref = "filemoji-compat" }
@@ -134,6 +137,7 @@ sparkbutton = { module = "com.github.connyduck:sparkbutton", version.ref = "spar
truth = { module = "com.google.truth:truth", version.ref = "truth" }
turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
unified-push = { module = "com.github.UnifiedPush:android-connector", version.ref = "unified-push" }
+xmlwriter = { module = "org.pageseeder.xmlwriter:pso-xmlwriter", version.ref = "xmlwriter" }
[bundles]
androidx = ["androidx-core-ktx", "androidx-appcompat", "androidx-fragment-ktx", "androidx-browser", "androidx-swiperefreshlayout",
@@ -153,3 +157,4 @@ okhttp = ["okhttp-core", "okhttp-logging-interceptor"]
retrofit = ["retrofit-core", "retrofit-converter-gson", "retrofit-adapter-rxjava3"]
room = ["androidx-room-ktx", "androidx-room-paging"]
rxjava3 = ["rxjava3-core", "rxjava3-android", "rxjava3-kotlin"]
+xmldiff = ["diffx", "xmlwriter"]