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 `<ins>`, deleted content with `<del>`.

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
This commit is contained in:
Nik Clayton 2023-03-10 20:30:55 +01:00 committed by GitHub
parent 43ea59ab2f
commit b9be125c95
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 349 additions and 9 deletions

View file

@ -160,6 +160,8 @@ dependencies {
implementation libs.bouncycastle implementation libs.bouncycastle
implementation libs.unified.push implementation libs.unified.push
implementation libs.bundles.xmldiff
testImplementation libs.androidx.test.junit testImplementation libs.androidx.test.junit
testImplementation libs.robolectric testImplementation libs.robolectric
testImplementation libs.bundles.mockito testImplementation libs.bundles.mockito

View file

@ -1,7 +1,17 @@
package com.keylesspalace.tusky.components.viewthread.edits 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.ColorDrawable
import android.graphics.drawable.Drawable 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.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup 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.unicodeWrap
import com.keylesspalace.tusky.util.visible import com.keylesspalace.tusky.util.visible
import com.keylesspalace.tusky.viewdata.toViewData import com.keylesspalace.tusky.viewdata.toViewData
import org.xml.sax.XMLReader
class ViewEditsAdapter( class ViewEditsAdapter(
private val edits: List<StatusEdit>, private val edits: List<StatusEdit>,
@ -47,11 +58,11 @@ class ViewEditsAdapter(
): BindingHolder<ItemStatusEditBinding> { ): BindingHolder<ItemStatusEditBinding> {
val binding = ItemStatusEditBinding.inflate(LayoutInflater.from(parent.context), parent, false) val binding = ItemStatusEditBinding.inflate(LayoutInflater.from(parent.context), parent, false)
binding.statusEditMediaPreview.clipToOutline = true binding.statusEditMediaPreview.clipToOutline = true
return BindingHolder(binding) return BindingHolder(binding)
} }
override fun onBindViewHolder(holder: BindingHolder<ItemStatusEditBinding>, position: Int) { override fun onBindViewHolder(holder: BindingHolder<ItemStatusEditBinding>, position: Int) {
val edit = edits[position] val edit = edits[position]
val binding = holder.binding 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) setClickableText(binding.statusEditContent, emojifiedText, emptyList(), emptyList(), listener)
if (edit.poll == null) { if (edit.poll == null) {
@ -184,3 +199,177 @@ class ViewEditsAdapter(
override fun getItemCount() = edits.size 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 <T> getLast(text: Spanned, kind: Class<T>): 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 <T> end(text: SpannableStringBuilder, mark: Class<T>, 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"
}
}

View file

@ -18,17 +18,32 @@ package com.keylesspalace.tusky.components.viewthread.edits
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import at.connyduck.calladapter.networkresult.fold 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.entity.StatusEdit
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch 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 import javax.inject.Inject
class ViewEditsViewModel @Inject constructor( class ViewEditsViewModel @Inject constructor(private val api: MastodonApi) : ViewModel() {
private val api: MastodonApi
) : ViewModel() {
private val _uiState: MutableStateFlow<EditsUiState> = MutableStateFlow(EditsUiState.Initial) private val _uiState: MutableStateFlow<EditsUiState> = MutableStateFlow(EditsUiState.Initial)
val uiState: StateFlow<EditsUiState> = _uiState.asStateFlow() val uiState: StateFlow<EditsUiState> = _uiState.asStateFlow()
@ -45,8 +60,59 @@ class ViewEditsViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
api.statusEdits(statusId).fold( api.statusEdits(statusId).fold(
{ edits -> { edits ->
val sortedEdits = edits.sortedBy { edit -> edit.createdAt }.reversed() // Diff each status' content against the previous version, producing new
_uiState.value = EditsUiState.Success(sortedEdits) // 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("<br>", "<br/>"))
var previousContent =
loader.load(sortedEdits[1].content.replace("<br>", "<br/>"))
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(
"<br>",
"<br/>"
)
)
}
}
_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 -> { throwable ->
_uiState.value = EditsUiState.Error(throwable) _uiState.value = EditsUiState.Error(throwable)
@ -54,6 +120,10 @@ class ViewEditsViewModel @Inject constructor(
) )
} }
} }
companion object {
const val TAG = "ViewEditsViewModel"
}
} }
sealed interface EditsUiState { sealed interface EditsUiState {
@ -68,3 +138,67 @@ sealed interface EditsUiState {
val edits: List<StatusEdit> val edits: List<StatusEdit>
) : EditsUiState ) : 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 <tusky-ins/tusky-del> 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)
}
}
}
}
}

View file

@ -16,6 +16,7 @@
@file:JvmName("StatusParsingHelper") @file:JvmName("StatusParsingHelper")
package com.keylesspalace.tusky.util package com.keylesspalace.tusky.util
import android.text.Html.TagHandler
import android.text.SpannableStringBuilder import android.text.SpannableStringBuilder
import android.text.Spanned import android.text.Spanned
import androidx.core.text.parseAsHtml 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 * 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("<br> ", "<br>&nbsp;") return this.replace("<br> ", "<br>&nbsp;")
.replace("<br /> ", "<br />&nbsp;") .replace("<br /> ", "<br />&nbsp;")
.replace("<br/> ", "<br/>&nbsp;") .replace("<br/> ", "<br/>&nbsp;")
.replace(" ", "&nbsp;&nbsp;") .replace(" ", "&nbsp;&nbsp;")
.parseAsHtml() .parseAsHtml(tagHandler = tagHandler)
/* Html.fromHtml returns trailing whitespace if the html ends in a </p> tag, which /* Html.fromHtml returns trailing whitespace if the html ends in a </p> tag, which
* most status contents do, so it should be trimmed. */ * most status contents do, so it should be trimmed. */
.trimTrailingWhitespace() .trimTrailingWhitespace()

View file

@ -28,4 +28,7 @@
<color name="botBadgeForeground">@color/white</color> <color name="botBadgeForeground">@color/white</color>
<color name="botBadgeBackground">@color/tusky_grey_10</color> <color name="botBadgeBackground">@color/tusky_grey_10</color>
<!-- colors used to show inserted/deleted text -->
<color name="view_edits_background_insert">#00731B</color>
<color name="view_edits_background_delete">#DF0000</color>
</resources> </resources>

View file

@ -61,4 +61,6 @@
<dimen name="preview_image_spacing">4dp</dimen> <dimen name="preview_image_spacing">4dp</dimen>
<dimen name="graph_line_thickness">1dp</dimen> <dimen name="graph_line_thickness">1dp</dimen>
<dimen name="lrPaddedSpanRadius">4dp</dimen>
</resources> </resources>

View file

@ -28,4 +28,7 @@
<color name="botBadgeForeground">@color/tusky_grey_20</color> <color name="botBadgeForeground">@color/tusky_grey_20</color>
<color name="botBadgeBackground">@color/white</color> <color name="botBadgeBackground">@color/white</color>
<!-- colors used to show inserted/deleted text -->
<color name="view_edits_background_insert">#CCFFD8</color>
<color name="view_edits_background_delete">#FFC0C0</color>
</resources> </resources>

View file

@ -24,6 +24,7 @@ bouncycastle = "1.70"
conscrypt = "2.5.2" conscrypt = "2.5.2"
coroutines = "1.6.4" coroutines = "1.6.4"
dagger = "2.45" dagger = "2.45"
diffx = "1.1.1"
emoji2 = "1.2.0" emoji2 = "1.2.0"
espresso = "3.5.1" espresso = "3.5.1"
filemoji-compat = "3.2.7" filemoji-compat = "3.2.7"
@ -50,6 +51,7 @@ sparkbutton = "4.1.0"
truth = "1.1.3" truth = "1.1.3"
turbine = "0.12.1" turbine = "0.12.1"
unified-push = "2.1.1" unified-push = "2.1.1"
xmlwriter = "1.0.4"
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } 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-android-support = { module = "com.google.dagger:dagger-android-support", version.ref = "dagger" }
dagger-compiler = { module = "com.google.dagger:dagger-compiler", version.ref = "dagger" } dagger-compiler = { module = "com.google.dagger:dagger-compiler", version.ref = "dagger" }
dagger-core = { module = "com.google.dagger:dagger", 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" } espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" }
filemojicompat-core = { module = "de.c1710:filemojicompat", version.ref = "filemoji-compat" } filemojicompat-core = { module = "de.c1710:filemojicompat", version.ref = "filemoji-compat" }
filemojicompat-defaults = { module = "de.c1710:filemojicompat-defaults", 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" } truth = { module = "com.google.truth:truth", version.ref = "truth" }
turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
unified-push = { module = "com.github.UnifiedPush:android-connector", version.ref = "unified-push" } unified-push = { module = "com.github.UnifiedPush:android-connector", version.ref = "unified-push" }
xmlwriter = { module = "org.pageseeder.xmlwriter:pso-xmlwriter", version.ref = "xmlwriter" }
[bundles] [bundles]
androidx = ["androidx-core-ktx", "androidx-appcompat", "androidx-fragment-ktx", "androidx-browser", "androidx-swiperefreshlayout", 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"] retrofit = ["retrofit-core", "retrofit-converter-gson", "retrofit-adapter-rxjava3"]
room = ["androidx-room-ktx", "androidx-room-paging"] room = ["androidx-room-ktx", "androidx-room-paging"]
rxjava3 = ["rxjava3-core", "rxjava3-android", "rxjava3-kotlin"] rxjava3 = ["rxjava3-core", "rxjava3-android", "rxjava3-kotlin"]
xmldiff = ["diffx", "xmlwriter"]