300 lines
11 KiB
Kotlin
300 lines
11 KiB
Kotlin
package com.keylesspalace.tusky.components.viewthread.edits
|
|
|
|
import android.content.Context
|
|
import android.graphics.Typeface.DEFAULT_BOLD
|
|
import android.graphics.drawable.ColorDrawable
|
|
import android.graphics.drawable.Drawable
|
|
import android.text.Editable
|
|
import android.text.SpannableStringBuilder
|
|
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
|
|
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.TuskyTagHandler
|
|
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.parseAsMastodonHtml
|
|
import com.keylesspalace.tusky.util.setClickableText
|
|
import com.keylesspalace.tusky.util.show
|
|
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()
|
|
|
|
/** Size of large text in this theme, in px */
|
|
private var largeTextSizePx: Float = 0f
|
|
|
|
/** Size of medium text in this theme, in px */
|
|
private 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)
|
|
}
|
|
|
|
override fun onBindViewHolder(holder: BindingHolder<ItemStatusEditBinding>, position: Int) {
|
|
val edit = edits[position]
|
|
|
|
val binding = holder.binding
|
|
|
|
val context = binding.root.context
|
|
|
|
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, timestamp)
|
|
|
|
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(EditsTagHandler(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
|
|
|
|
companion object {
|
|
private const val VIEW_TYPE_EDITS_NEWEST = 0
|
|
private const val VIEW_TYPE_EDITS = 1
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle XML tags created by [ViewEditsViewModel] and create custom spans to display inserted or
|
|
* deleted text.
|
|
*/
|
|
class EditsTagHandler(val context: Context) : TuskyTagHandler() {
|
|
/** 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)
|
|
)
|
|
}
|
|
}
|
|
else -> super.handleTag(opening, tag, output, xmlReader)
|
|
}
|
|
}
|
|
|
|
/** Span that signifies deleted text */
|
|
class DeletedTextSpan(context: Context) : CharacterStyle() {
|
|
private var bgColor: Int
|
|
|
|
init {
|
|
bgColor = context.getColor(R.color.view_edits_background_delete)
|
|
}
|
|
|
|
override fun updateDrawState(tp: TextPaint) {
|
|
tp.bgColor = bgColor
|
|
tp.isStrikeThruText = true
|
|
}
|
|
}
|
|
|
|
/** Span that signifies inserted text */
|
|
class InsertedTextSpan(context: Context) : CharacterStyle() {
|
|
private var bgColor: Int
|
|
|
|
init {
|
|
bgColor = context.getColor(R.color.view_edits_background_insert)
|
|
}
|
|
|
|
override fun updateDrawState(tp: TextPaint) {
|
|
tp.bgColor = bgColor
|
|
tp.typeface = DEFAULT_BOLD
|
|
}
|
|
}
|
|
|
|
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"
|
|
}
|
|
}
|