Yuito-app-android/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsAdapter.kt

376 lines
14 KiB
Kotlin

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
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.google.android.material.color.MaterialColors
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.PollAdapter
import com.keylesspalace.tusky.adapter.PollAdapter.Companion.MULTIPLE
import com.keylesspalace.tusky.adapter.PollAdapter.Companion.SINGLE
import com.keylesspalace.tusky.databinding.ItemStatusEditBinding
import com.keylesspalace.tusky.entity.Attachment.Focus
import com.keylesspalace.tusky.entity.StatusEdit
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.util.AbsoluteTimeFormatter
import com.keylesspalace.tusky.util.BindingHolder
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
class ViewEditsAdapter(
private val edits: List<StatusEdit>,
private val animateAvatars: Boolean,
private val animateEmojis: Boolean,
private val useBlurhash: Boolean,
private val listener: LinkListener
) : RecyclerView.Adapter<BindingHolder<ItemStatusEditBinding>>() {
private val absoluteTimeFormatter = AbsoluteTimeFormatter()
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): 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
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) {
R.string.status_created_info
} else {
R.string.status_edit_info
}
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)
if (edit.spoilerText.isEmpty()) {
binding.statusEditContentWarningDescription.hide()
binding.statusEditContentWarningSeparator.hide()
} else {
binding.statusEditContentWarningDescription.show()
binding.statusEditContentWarningSeparator.show()
binding.statusEditContentWarningDescription.text = edit.spoilerText.emojify(
edit.emojis,
binding.statusEditContentWarningDescription,
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) {
binding.statusEditPollOptions.hide()
binding.statusEditPollDescription.hide()
} else {
binding.statusEditPollOptions.show()
// not used for now since not reported by the api
// https://github.com/mastodon/mastodon/issues/22571
// binding.statusEditPollDescription.show()
val pollAdapter = PollAdapter()
binding.statusEditPollOptions.adapter = pollAdapter
binding.statusEditPollOptions.layoutManager = LinearLayoutManager(context)
pollAdapter.setup(
options = edit.poll.options.map { it.toViewData(false) },
voteCount = 0,
votersCount = null,
emojis = edit.emojis,
mode = if (edit.poll.multiple) { // not reported by the api
MULTIPLE
} else {
SINGLE
},
resultClickListener = null,
animateEmojis = animateEmojis,
enabled = false
)
}
if (edit.mediaAttachments.isEmpty()) {
binding.statusEditMediaPreview.hide()
binding.statusEditMediaSensitivity.hide()
} else {
binding.statusEditMediaPreview.show()
binding.statusEditMediaPreview.aspectRatios = edit.mediaAttachments.aspectRatios()
binding.statusEditMediaPreview.forEachIndexed { index, imageView, descriptionIndicator ->
val attachment = edit.mediaAttachments[index]
val hasDescription = !attachment.description.isNullOrBlank()
if (hasDescription) {
imageView.contentDescription = attachment.description
} else {
imageView.contentDescription =
imageView.context.getString(R.string.action_view_media)
}
descriptionIndicator.visibility = if (hasDescription) View.VISIBLE else View.GONE
val blurhash = attachment.blurhash
val placeholder: Drawable = if (blurhash != null && useBlurhash) {
decodeBlurHash(context, blurhash)
} else {
ColorDrawable(MaterialColors.getColor(imageView, R.attr.colorBackgroundAccent))
}
if (attachment.previewUrl.isNullOrEmpty()) {
imageView.removeFocalPoint()
Glide.with(imageView)
.load(placeholder)
.centerInside()
.into(imageView)
} else {
val focus: Focus? = attachment.meta?.focus
if (focus != null) {
imageView.setFocalPoint(focus)
Glide.with(imageView.context)
.load(attachment.previewUrl)
.placeholder(placeholder)
.centerInside()
.addListener(imageView)
.into(imageView)
} else {
imageView.removeFocalPoint()
Glide.with(imageView)
.load(attachment.previewUrl)
.placeholder(placeholder)
.centerInside()
.into(imageView)
}
}
}
binding.statusEditMediaSensitivity.visible(edit.sensitive)
}
}
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"
}
}