Remove ReplacementSpan, display diffs using CharacterStyle (#3431)

Remove the use of ReplacementSpan. It turns out this span type is incompatible with spans that occupy more than one line, and the result is that a longer diff can run off the end of the screen. The alternative means that the diff'd text doesn't have additional padding and rounded corners, but it's better than not being visible.

Display the most recent version of the status with larger text. Again, consistent with the thread view.

Display the avatar, name, and username of the poster in a pinned header at the top of the screen, instead of duplicating the information on every edit. This reduces the amount of redundant information on the screen.
This commit is contained in:
Nik Clayton 2023-06-11 19:12:05 +02:00 committed by GitHub
commit 66a394245b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 266 additions and 223 deletions

View file

@ -1,8 +1,6 @@
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
@ -11,7 +9,9 @@ import android.text.Html
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.style.ReplacementSpan
import android.text.TextPaint
import android.text.style.CharacterStyle
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@ -33,11 +33,9 @@ import com.keylesspalace.tusky.util.aspectRatios
import com.keylesspalace.tusky.util.decodeBlurHash
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.parseAsMastodonHtml
import com.keylesspalace.tusky.util.setClickableText
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
@ -52,13 +50,28 @@ class ViewEditsAdapter(
private val absoluteTimeFormatter = AbsoluteTimeFormatter()
/** Size of large text in this theme, in px */
var largeTextSizePx: Float = 0f
/** Size of medium text in this theme, in px */
var mediumTextSizePx: Float = 0f
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): BindingHolder<ItemStatusEditBinding> {
val binding = ItemStatusEditBinding.inflate(LayoutInflater.from(parent.context), parent, false)
binding.statusEditMediaPreview.clipToOutline = true
val typedValue = TypedValue()
val context = binding.root.context
val displayMetrics = context.resources.displayMetrics
context.theme.resolveAttribute(R.attr.status_text_large, typedValue, true)
largeTextSizePx = typedValue.getDimension(displayMetrics)
context.theme.resolveAttribute(R.attr.status_text_medium, typedValue, true)
mediumTextSizePx = typedValue.getDimension(displayMetrics)
return BindingHolder(binding)
}
@ -69,24 +82,26 @@ class ViewEditsAdapter(
val context = binding.root.context
val avatarRadius: Int = context.resources
.getDimensionPixelSize(R.dimen.avatar_radius_48dp)
loadAvatar(edit.account.avatar, binding.statusEditAvatar, avatarRadius, animateAvatars)
val infoStringRes = if (position == edits.size - 1) {
val infoStringRes = if (position == edits.lastIndex) {
R.string.status_created_info
} else {
R.string.status_edit_info
}
// Show the most recent version of the status using large text to make it clearer for
// the user, and for similarity with thread view.
val variableTextSize = if (position == edits.lastIndex) {
mediumTextSizePx
} else {
largeTextSizePx
}
binding.statusEditContentWarningDescription.setTextSize(TypedValue.COMPLEX_UNIT_PX, variableTextSize)
binding.statusEditContent.setTextSize(TypedValue.COMPLEX_UNIT_PX, variableTextSize)
binding.statusEditMediaSensitivity.setTextSize(TypedValue.COMPLEX_UNIT_PX, variableTextSize)
val timestamp = absoluteTimeFormatter.format(edit.createdAt, false)
binding.statusEditInfo.text = context.getString(
infoStringRes,
edit.account.name.unicodeWrap(),
timestamp
).emojify(edit.account.emojis, binding.statusEditInfo, animateEmojis)
binding.statusEditInfo.text = context.getString(infoStringRes, timestamp)
if (edit.spoilerText.isEmpty()) {
binding.statusEditContentWarningDescription.hide()
@ -198,6 +213,11 @@ class ViewEditsAdapter(
}
override fun getItemCount() = edits.size
companion object {
private const val VIEW_TYPE_EDITS_NEWEST = 0
private const val VIEW_TYPE_EDITS = 1
}
}
/**
@ -266,98 +286,31 @@ class TuskyTagHandler(val context: Context) : Html.TagHandler {
}
}
/**
* 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
class DeletedTextSpan(context: Context) : CharacterStyle() {
private var bgColor: Int
init {
bgPaint.color = context.getColor(R.color.view_edits_background_delete)
radius = context.resources.getDimension(R.dimen.lrPaddedSpanRadius)
bgColor = context.getColor(R.color.view_edits_background_delete)
}
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)
override fun updateDrawState(tp: TextPaint) {
tp.bgColor = bgColor
tp.isStrikeThruText = true
}
}
/** Span that signifies inserted text */
class InsertedTextSpan(context: Context) : LRPaddedSpan() {
val bgPaint = Paint()
val radius: Float
class InsertedTextSpan(context: Context) : CharacterStyle() {
private var bgColor: Int
init {
bgPaint.color = context.getColor(R.color.view_edits_background_insert)
radius = context.resources.getDimension(R.dimen.lrPaddedSpanRadius)
bgColor = context.getColor(R.color.view_edits_background_insert)
}
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)
override fun updateDrawState(tp: TextPaint) {
tp.bgColor = bgColor
tp.typeface = DEFAULT_BOLD
}
}

View file

@ -37,13 +37,16 @@ import com.keylesspalace.tusky.BottomSheetActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.components.account.AccountActivity
import com.keylesspalace.tusky.databinding.FragmentViewThreadBinding
import com.keylesspalace.tusky.databinding.FragmentViewEditsBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.unicodeWrap
import com.keylesspalace.tusky.util.viewBinding
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
@ -54,7 +57,7 @@ import java.io.IOException
import javax.inject.Inject
class ViewEditsFragment :
Fragment(R.layout.fragment_view_thread),
Fragment(R.layout.fragment_view_edits),
LinkListener,
OnRefreshListener,
MenuProvider,
@ -65,7 +68,7 @@ class ViewEditsFragment :
private val viewModel: ViewEditsViewModel by viewModels { viewModelFactory }
private val binding by viewBinding(FragmentViewThreadBinding::bind)
private val binding by viewBinding(FragmentViewEditsBinding::bind)
private lateinit var statusId: String
@ -88,6 +91,7 @@ class ViewEditsFragment :
val animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false)
val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
val useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true)
val avatarRadius: Int = requireContext().resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp)
viewLifecycleOwner.lifecycleScope.launch {
viewModel.uiState.collect { uiState ->
@ -130,6 +134,15 @@ class ViewEditsFragment :
useBlurhash = useBlurhash,
listener = this@ViewEditsFragment
)
// Focus on the most recent version
(binding.recyclerView.layoutManager as LinearLayoutManager).scrollToPosition(0)
val account = uiState.edits.first().account
loadAvatar(account.avatar, binding.statusAvatar, avatarRadius, animateAvatars)
binding.statusDisplayName.text = account.name.unicodeWrap().emojify(account.emojis, binding.statusDisplayName, animateEmojis)
binding.statusUsername.text = account.username
}
}
}

View file

@ -98,10 +98,7 @@ class ViewEditsViewModel @Inject constructor(private val api: MastodonApi) : Vie
if (i < sortedEdits.size - 1) {
currentContent = previousContent
previousContent = loader.load(
sortedEdits[i + 1].content.replace(
"<br>",
"<br/>"
)
sortedEdits[i + 1].content.replace("<br>", "<br/>")
)
}
}