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.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
|
||||||
|
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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> ")
|
return this.replace("<br> ", "<br> ")
|
||||||
.replace("<br /> ", "<br /> ")
|
.replace("<br /> ", "<br /> ")
|
||||||
.replace("<br/> ", "<br/> ")
|
.replace("<br/> ", "<br/> ")
|
||||||
.replace(" ", " ")
|
.replace(" ", " ")
|
||||||
.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()
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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"]
|
||||||
|
|
Loading…
Reference in a new issue