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:
parent
43ea59ab2f
commit
b9be125c95
8 changed files with 349 additions and 9 deletions
|
@ -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
|
||||
|
|
|
@ -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<StatusEdit>,
|
||||
|
@ -47,11 +58,11 @@ class ViewEditsAdapter(
|
|||
): BindingHolder<ItemStatusEditBinding> {
|
||||
val binding = ItemStatusEditBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
binding.statusEditMediaPreview.clipToOutline = true
|
||||
|
||||
return BindingHolder(binding)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: BindingHolder<ItemStatusEditBinding>, 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 <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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<EditsUiState> = MutableStateFlow(EditsUiState.Initial)
|
||||
val uiState: StateFlow<EditsUiState> = _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()
|
||||
// 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("<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 ->
|
||||
_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<StatusEdit>
|
||||
) : 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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("<br> ", "<br> ")
|
||||
.replace("<br /> ", "<br /> ")
|
||||
.replace("<br/> ", "<br/> ")
|
||||
.replace(" ", " ")
|
||||
.parseAsHtml()
|
||||
.parseAsHtml(tagHandler = tagHandler)
|
||||
/* Html.fromHtml returns trailing whitespace if the html ends in a </p> tag, which
|
||||
* most status contents do, so it should be trimmed. */
|
||||
.trimTrailingWhitespace()
|
||||
|
|
|
@ -28,4 +28,7 @@
|
|||
<color name="botBadgeForeground">@color/white</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>
|
||||
|
|
|
@ -61,4 +61,6 @@
|
|||
<dimen name="preview_image_spacing">4dp</dimen>
|
||||
|
||||
<dimen name="graph_line_thickness">1dp</dimen>
|
||||
|
||||
<dimen name="lrPaddedSpanRadius">4dp</dimen>
|
||||
</resources>
|
||||
|
|
|
@ -28,4 +28,7 @@
|
|||
<color name="botBadgeForeground">@color/tusky_grey_20</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>
|
||||
|
|
|
@ -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"]
|
||||
|
|
Loading…
Reference in a new issue