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, private val animateAvatars: Boolean, private val animateEmojis: Boolean, private val useBlurhash: Boolean, private val listener: LinkListener ) : RecyclerView.Adapter>() { 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 { 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, 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" } }