refactor: Convert Java viewholders to Kotlin (#200)

This commit is contained in:
Nik Clayton 2023-10-26 16:22:18 +02:00 committed by GitHub
parent 0598c0e667
commit 2f3851acee
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 1683 additions and 1683 deletions

View File

@ -740,7 +740,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="335"
line="337"
column="5"/>
</issue>
@ -751,7 +751,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="392"
line="394"
column="5"/>
</issue>
@ -762,7 +762,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="565"
line="567"
column="5"/>
</issue>
@ -773,7 +773,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="780"
line="782"
column="5"/>
</issue>
@ -1415,17 +1415,6 @@
column="1"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.color.seed` appears to be unused"
errorLine1=" &lt;color name=&quot;seed&quot;>#006382&lt;/color>"
errorLine2=" ~~~~~~~~~~~">
<location
file="src/main/res/values/colors.xml"
line="4"
column="12"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.color.md_theme_light_shadow` appears to be unused"
@ -1796,7 +1785,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="165"
line="167"
column="13"/>
</issue>
@ -1807,7 +1796,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="228"
line="230"
column="13"/>
</issue>
@ -1818,7 +1807,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="268"
line="270"
column="13"/>
</issue>
@ -1829,7 +1818,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="269"
line="271"
column="13"/>
</issue>
@ -1840,7 +1829,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="294"
line="296"
column="13"/>
</issue>
@ -1851,7 +1840,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="325"
line="327"
column="13"/>
</issue>
@ -1862,7 +1851,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="405"
line="407"
column="13"/>
</issue>
@ -1873,7 +1862,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="462"
line="464"
column="13"/>
</issue>
@ -1884,7 +1873,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="513"
line="515"
column="13"/>
</issue>
@ -1895,7 +1884,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="517"
line="519"
column="13"/>
</issue>
@ -1906,7 +1895,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="518"
line="520"
column="13"/>
</issue>
@ -1917,7 +1906,7 @@
errorLine2=" ~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="519"
line="521"
column="13"/>
</issue>
@ -1928,7 +1917,7 @@
errorLine2=" ~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="520"
line="522"
column="13"/>
</issue>
@ -1939,7 +1928,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="521"
line="523"
column="13"/>
</issue>
@ -1950,7 +1939,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="522"
line="524"
column="13"/>
</issue>
@ -1961,7 +1950,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="523"
line="525"
column="13"/>
</issue>
@ -1972,7 +1961,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="524"
line="526"
column="13"/>
</issue>
@ -1983,7 +1972,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="526"
line="528"
column="13"/>
</issue>
@ -1994,7 +1983,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="604"
line="606"
column="13"/>
</issue>
@ -2005,7 +1994,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="605"
line="607"
column="13"/>
</issue>
@ -2016,7 +2005,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="620"
line="622"
column="13"/>
</issue>
@ -2027,7 +2016,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="622"
line="624"
column="13"/>
</issue>
@ -2038,7 +2027,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="623"
line="625"
column="13"/>
</issue>
@ -2049,7 +2038,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="626"
line="628"
column="13"/>
</issue>
@ -2060,7 +2049,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="671"
line="673"
column="13"/>
</issue>
@ -2071,7 +2060,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="719"
line="721"
column="13"/>
</issue>
@ -2082,7 +2071,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="725"
line="727"
column="13"/>
</issue>
@ -2093,7 +2082,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="768"
line="770"
column="13"/>
</issue>
@ -2104,7 +2093,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="802"
line="804"
column="13"/>
</issue>
@ -3703,6 +3692,39 @@
column="48"/>
</issue>
<issue
id="SyntheticAccessor"
message="Access to `private` method `getContentWarningDescription` of class `Companion` requires synthetic accessor"
errorLine1=" getContentWarningDescription(context, status),"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/app/pachli/adapter/StatusBaseViewHolder.kt"
line="772"
column="13"/>
</issue>
<issue
id="SyntheticAccessor"
message="Access to `private` method `getReblogDescription` of class `Companion` requires synthetic accessor"
errorLine1=" getReblogDescription(context, status),"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/app/pachli/adapter/StatusBaseViewHolder.kt"
line="776"
column="13"/>
</issue>
<issue
id="SyntheticAccessor"
message="Access to `private` method `getMediaDescription` of class `Companion` requires synthetic accessor"
errorLine1=" getMediaDescription(context, status),"
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/app/pachli/adapter/StatusBaseViewHolder.kt"
line="781"
column="13"/>
</issue>
<issue
id="SyntheticAccessor"
message="Access to `private` method `viewdata` of class `StatusViewHolder` requires synthetic accessor"
@ -3886,18 +3908,18 @@
errorLine2=" ~~~~~~~">
<location
file="src/main/java/app/pachli/fragment/ViewVideoFragment.kt"
line="140"
line="152"
column="32"/>
</issue>
<issue
id="SyntheticAccessor"
message="Access to `private` method `hideToolbarAfterDelay` of class `ViewVideoFragment` requires synthetic accessor"
errorLine1=" hideToolbarAfterDelay(TOOLBAR_HIDE_DELAY_MS)"
errorLine1=" hideToolbarAfterDelay()"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/app/pachli/fragment/ViewVideoFragment.kt"
line="221"
line="232"
column="21"/>
</issue>
@ -4036,7 +4058,7 @@
errorLine2=" ~~~~~~~~">
<location
file="src/main/res/layout/activity_account.xml"
line="131"
line="129"
column="22"/>
</issue>
@ -4047,7 +4069,7 @@
errorLine2=" ~~~~~~~~">
<location
file="src/main/res/layout/activity_account.xml"
line="246"
line="242"
column="22"/>
</issue>
@ -4058,7 +4080,7 @@
errorLine2=" ~~~~~~~~">
<location
file="src/main/res/layout/activity_account.xml"
line="282"
line="278"
column="26"/>
</issue>
@ -4069,7 +4091,7 @@
errorLine2=" ~~~~~~~~">
<location
file="src/main/res/layout/activity_account.xml"
line="309"
line="305"
column="26"/>
</issue>
@ -4080,7 +4102,7 @@
errorLine2=" ~~~~~~~~">
<location
file="src/main/res/layout/activity_account.xml"
line="324"
line="320"
column="26"/>
</issue>
@ -4091,7 +4113,7 @@
errorLine2=" ~~~~~~~~">
<location
file="src/main/res/layout/activity_account.xml"
line="351"
line="347"
column="26"/>
</issue>
@ -4102,7 +4124,7 @@
errorLine2=" ~~~~~~~~">
<location
file="src/main/res/layout/activity_account.xml"
line="382"
line="378"
column="26"/>
</issue>
@ -4113,7 +4135,7 @@
errorLine2=" ~~~~~~~~">
<location
file="src/main/res/layout/activity_account.xml"
line="412"
line="408"
column="26"/>
</issue>

View File

@ -0,0 +1,88 @@
/*
* Copyright 2023 Pachli Association
*
* This file is a part of Pachli.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Pachli; if not,
* see <http://www.gnu.org/licenses>.
*/
package app.pachli.adapter
import android.view.View
import app.pachli.R
import app.pachli.databinding.ItemStatusWrapperBinding
import app.pachli.entity.Filter
import app.pachli.interfaces.StatusActionListener
import app.pachli.util.StatusDisplayOptions
import app.pachli.viewdata.StatusViewData
open class FilterableStatusViewHolder(
private val binding: ItemStatusWrapperBinding,
) : StatusViewHolder(binding.statusContainer, binding.root) {
override fun setupWithStatus(
status: StatusViewData,
listener: StatusActionListener,
statusDisplayOptions: StatusDisplayOptions,
payloads: Any?,
) {
super.setupWithStatus(status, listener, statusDisplayOptions, payloads)
setupFilterPlaceholder(status, listener)
}
private fun setupFilterPlaceholder(
status: StatusViewData,
listener: StatusActionListener,
) {
if (status.filterAction !== Filter.Action.WARN) {
showFilteredPlaceholder(false)
return
}
// Shouldn't be necessary given the previous test against getFilterAction(),
// but guards against a possible NPE. See the TODO in StatusViewData.filterAction
// for more details.
val filterResults = status.actionable.filtered
if (filterResults.isNullOrEmpty()) {
showFilteredPlaceholder(false)
return
}
var matchedFilter: Filter? = null
for ((filter) in filterResults) {
if (filter.action === Filter.Action.WARN) {
matchedFilter = filter
break
}
}
// Guard against a possible NPE
if (matchedFilter == null) {
showFilteredPlaceholder(false)
return
}
showFilteredPlaceholder(true)
binding.statusFilteredPlaceholder.statusFilterLabel.text = context.getString(
R.string.status_filter_placeholder_label_format,
matchedFilter.title,
)
binding.statusFilteredPlaceholder.statusFilterShowAnyway.setOnClickListener {
listener.clearWarningAction(
bindingAdapterPosition,
)
}
}
private fun showFilteredPlaceholder(show: Boolean) {
binding.statusContainer.root.visibility = if (show) View.GONE else View.VISIBLE
binding.statusFilteredPlaceholder.root.visibility = if (show) View.VISIBLE else View.GONE
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,915 @@
package app.pachli.adapter
import android.content.Context
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.text.TextUtils
import android.text.format.DateUtils
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.widget.PopupMenu
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat
import androidx.core.text.HtmlCompat
import androidx.recyclerview.widget.RecyclerView
import app.pachli.R
import app.pachli.ViewMediaActivity.Companion.newSingleImageIntent
import app.pachli.entity.Attachment
import app.pachli.entity.Emoji
import app.pachli.entity.PreviewCardKind
import app.pachli.entity.Status
import app.pachli.entity.description
import app.pachli.interfaces.StatusActionListener
import app.pachli.util.AbsoluteTimeFormatter
import app.pachli.util.CardViewMode
import app.pachli.util.CompositeWithOpaqueBackground
import app.pachli.util.StatusDisplayOptions
import app.pachli.util.aspectRatios
import app.pachli.util.decodeBlurHash
import app.pachli.util.emojify
import app.pachli.util.expandTouchSizeToFillRow
import app.pachli.util.formatNumber
import app.pachli.util.getFormattedDescription
import app.pachli.util.getRelativeTimeSpanString
import app.pachli.util.hide
import app.pachli.util.loadAvatar
import app.pachli.util.setClickableMentions
import app.pachli.util.setClickableText
import app.pachli.view.MediaPreviewImageView
import app.pachli.view.MediaPreviewLayout
import app.pachli.view.PollView
import app.pachli.view.PreviewCardView
import app.pachli.viewdata.PollViewData.Companion.from
import app.pachli.viewdata.StatusViewData
import at.connyduck.sparkbutton.SparkButton
import at.connyduck.sparkbutton.helpers.Utils
import com.bumptech.glide.Glide
import com.google.android.material.button.MaterialButton
import com.google.android.material.color.MaterialColors
import java.text.NumberFormat
import java.util.Date
abstract class StatusBaseViewHolder protected constructor(itemView: View) :
RecyclerView.ViewHolder(itemView) {
object Key {
const val KEY_CREATED = "created"
}
protected val context: Context
private val displayName: TextView
private val username: TextView
private val replyButton: ImageButton
private val replyCountLabel: TextView?
private val reblogButton: SparkButton?
private val favouriteButton: SparkButton
private val bookmarkButton: SparkButton
private val moreButton: ImageButton
private val mediaContainer: ConstraintLayout
protected val mediaPreview: MediaPreviewLayout
private val sensitiveMediaWarning: TextView
private val sensitiveMediaShow: View
protected val mediaLabels: Array<TextView>
private val mediaDescriptions: Array<CharSequence?>
private val contentWarningButton: MaterialButton
private val avatarInset: ImageView
val avatar: ImageView
val metaInfo: TextView
val content: TextView
private val contentWarningDescription: TextView
private val pollView: PollView
private val cardView: PreviewCardView?
private val filteredPlaceholder: LinearLayout?
private val filteredPlaceholderLabel: TextView?
private val filteredPlaceholderShowButton: Button?
private val statusContainer: ConstraintLayout?
private val numberFormat = NumberFormat.getNumberInstance()
private val absoluteTimeFormatter = AbsoluteTimeFormatter()
protected val avatarRadius48dp: Int
private val avatarRadius36dp: Int
private val avatarRadius24dp: Int
private val mediaPreviewUnloaded: Drawable
init {
context = itemView.context
displayName = itemView.findViewById(R.id.status_display_name)
username = itemView.findViewById(R.id.status_username)
metaInfo = itemView.findViewById(R.id.status_meta_info)
content = itemView.findViewById(R.id.status_content)
avatar = itemView.findViewById(R.id.status_avatar)
replyButton = itemView.findViewById(R.id.status_reply)
replyCountLabel = itemView.findViewById(R.id.status_replies)
reblogButton = itemView.findViewById(R.id.status_inset)
favouriteButton = itemView.findViewById(R.id.status_favourite)
bookmarkButton = itemView.findViewById(R.id.status_bookmark)
moreButton = itemView.findViewById(R.id.status_more)
mediaContainer = itemView.findViewById(R.id.status_media_preview_container)
mediaContainer.clipToOutline = true
mediaPreview = itemView.findViewById(R.id.status_media_preview)
sensitiveMediaWarning = itemView.findViewById(R.id.status_sensitive_media_warning)
sensitiveMediaShow = itemView.findViewById(R.id.status_sensitive_media_button)
mediaLabels = arrayOf(
itemView.findViewById(R.id.status_media_label_0),
itemView.findViewById(R.id.status_media_label_1),
itemView.findViewById(R.id.status_media_label_2),
itemView.findViewById(R.id.status_media_label_3),
)
mediaDescriptions = arrayOfNulls(mediaLabels.size)
contentWarningDescription = itemView.findViewById(R.id.status_content_warning_description)
contentWarningButton = itemView.findViewById(R.id.status_content_warning_button)
avatarInset = itemView.findViewById(R.id.status_avatar_inset)
pollView = itemView.findViewById(R.id.status_poll)
cardView = itemView.findViewById(R.id.status_card_view)
filteredPlaceholder = itemView.findViewById(R.id.status_filtered_placeholder)
filteredPlaceholderLabel = itemView.findViewById(R.id.status_filter_label)
filteredPlaceholderShowButton = itemView.findViewById(R.id.status_filter_show_anyway)
statusContainer = itemView.findViewById(R.id.status_container)
avatarRadius48dp = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp)
avatarRadius36dp = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp)
avatarRadius24dp = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_24dp)
mediaPreviewUnloaded =
ColorDrawable(MaterialColors.getColor(itemView, android.R.attr.textColorLink))
(itemView as ViewGroup).expandTouchSizeToFillRow(
listOfNotNull(
replyButton,
reblogButton,
favouriteButton,
bookmarkButton,
moreButton,
),
)
}
protected fun setDisplayName(
name: String,
customEmojis: List<Emoji>?,
statusDisplayOptions: StatusDisplayOptions,
) {
displayName.text = name.emojify(customEmojis, displayName, statusDisplayOptions.animateEmojis)
}
protected fun setUsername(name: String) {
username.text = context.getString(R.string.post_username_format, name)
}
fun toggleContentWarning() {
contentWarningButton.performClick()
}
protected fun setSpoilerAndContent(
status: StatusViewData,
statusDisplayOptions: StatusDisplayOptions,
listener: StatusActionListener,
) {
val (_, _, _, _, _, _, _, _, _, emojis) = status.actionable
val spoilerText = status.spoilerText
val sensitive = !TextUtils.isEmpty(spoilerText)
val expanded = status.isExpanded
if (sensitive) {
val emojiSpoiler = spoilerText.emojify(
emojis,
contentWarningDescription,
statusDisplayOptions.animateEmojis,
)
contentWarningDescription.text = emojiSpoiler
contentWarningDescription.visibility = View.VISIBLE
contentWarningButton.visibility = View.VISIBLE
setContentWarningButtonText(expanded)
contentWarningButton.setOnClickListener {
toggleExpandedState(
true,
!expanded,
status,
statusDisplayOptions,
listener,
)
}
setTextVisible(true, expanded, status, statusDisplayOptions, listener)
return
}
contentWarningDescription.visibility = View.GONE
contentWarningButton.visibility = View.GONE
setTextVisible(
sensitive = false,
expanded = true,
status = status,
statusDisplayOptions = statusDisplayOptions,
listener = listener,
)
}
private fun setContentWarningButtonText(expanded: Boolean) {
if (expanded) {
contentWarningButton.setText(R.string.post_content_warning_show_less)
} else {
contentWarningButton.setText(R.string.post_content_warning_show_more)
}
}
protected open fun toggleExpandedState(
sensitive: Boolean,
expanded: Boolean,
status: StatusViewData,
statusDisplayOptions: StatusDisplayOptions,
listener: StatusActionListener,
) {
contentWarningDescription.invalidate()
val adapterPosition = bindingAdapterPosition
if (adapterPosition != RecyclerView.NO_POSITION) {
listener.onExpandedChange(expanded, adapterPosition)
}
setContentWarningButtonText(expanded)
setTextVisible(sensitive, expanded, status, statusDisplayOptions, listener)
setupCard(
status,
expanded,
statusDisplayOptions.cardViewMode,
statusDisplayOptions,
listener,
)
}
private fun setTextVisible(
sensitive: Boolean,
expanded: Boolean,
status: StatusViewData,
statusDisplayOptions: StatusDisplayOptions,
listener: StatusActionListener,
) {
val (_, _, _, _, _, _, _, _, _, emojis, _, _, _, _, _, _, _, _, _, _, mentions, tags, _, _, _, poll) = status.actionable
val content = status.content
if (expanded) {
val emojifiedText =
content.emojify(emojis, this.content, statusDisplayOptions.animateEmojis)
setClickableText(this.content, emojifiedText, mentions, tags, listener)
for (i in mediaLabels.indices) {
updateMediaLabel(i, sensitive, true)
}
poll?.let {
pollView.bind(
from(it),
emojis,
statusDisplayOptions,
numberFormat,
absoluteTimeFormatter,
) { choices ->
val position = bindingAdapterPosition
if (position != RecyclerView.NO_POSITION) {
choices?.let { listener.onVoteInPoll(position, it) }
?: listener.onViewThread(position)
}
}
} ?: pollView.hide()
} else {
pollView.hide()
setClickableMentions(this.content, mentions, listener)
}
if (TextUtils.isEmpty(this.content.text)) {
this.content.visibility = View.GONE
} else {
this.content.visibility = View.VISIBLE
}
}
private fun setAvatar(
url: String,
rebloggedUrl: String?,
isBot: Boolean,
statusDisplayOptions: StatusDisplayOptions,
) {
val avatarRadius: Int
if (TextUtils.isEmpty(rebloggedUrl)) {
avatar.setPaddingRelative(0, 0, 0, 0)
if (statusDisplayOptions.showBotOverlay && isBot) {
avatarInset.visibility = View.VISIBLE
Glide.with(avatarInset)
.load(R.drawable.bot_badge)
.into(avatarInset)
} else {
avatarInset.visibility = View.GONE
}
avatarRadius = avatarRadius48dp
} else {
val padding = Utils.dpToPx(context, 12)
avatar.setPaddingRelative(0, 0, padding, padding)
avatarInset.visibility = View.VISIBLE
avatarInset.background = null
loadAvatar(
rebloggedUrl,
avatarInset,
avatarRadius24dp,
statusDisplayOptions.animateAvatars,
null,
)
avatarRadius = avatarRadius36dp
}
loadAvatar(
url,
avatar,
avatarRadius,
statusDisplayOptions.animateAvatars,
listOf(CompositeWithOpaqueBackground(avatar)),
)
}
protected open fun setMetaData(
statusViewData: StatusViewData,
statusDisplayOptions: StatusDisplayOptions,
listener: StatusActionListener,
) {
val (_, _, _, _, _, _, _, createdAt, editedAt) = statusViewData.actionable
var timestampText: String
timestampText = if (statusDisplayOptions.useAbsoluteTime) {
absoluteTimeFormatter.format(createdAt, true)
} else {
val then = createdAt.time
val now = System.currentTimeMillis()
getRelativeTimeSpanString(context, then, now)
}
editedAt?.also {
timestampText = context.getString(
R.string.post_timestamp_with_edited_indicator,
timestampText,
)
}
metaInfo.text = timestampText
}
private fun getCreatedAtDescription(
createdAt: Date?,
statusDisplayOptions: StatusDisplayOptions,
): CharSequence {
return if (statusDisplayOptions.useAbsoluteTime) {
absoluteTimeFormatter.format(createdAt, true)
} else {
/* This one is for screen-readers. Frequently, they would mispronounce timestamps like "17m"
* as 17 meters instead of minutes. */
createdAt?.let {
val then = createdAt.time
val now = System.currentTimeMillis()
DateUtils.getRelativeTimeSpanString(
then,
now,
DateUtils.SECOND_IN_MILLIS,
DateUtils.FORMAT_ABBREV_RELATIVE,
)
} ?: "? minutes"
}
}
protected fun setIsReply(isReply: Boolean) {
val drawable = if (isReply) R.drawable.ic_reply_all_24dp else R.drawable.ic_reply_24dp
replyButton.setImageResource(drawable)
}
private fun setReplyCount(repliesCount: Int, fullStats: Boolean) {
// This label only exists in the non-detailed view (to match the web ui)
replyCountLabel ?: return
if (fullStats) {
replyCountLabel.text = formatNumber(repliesCount.toLong(), 1000)
return
}
// Show "0", "1", or "1+" for replies otherwise, so the user knows if there is a thread
// that they can click through to read.
replyCountLabel.text =
if (repliesCount > 1) context.getString(R.string.status_count_one_plus) else repliesCount.toString()
}
private fun setReblogged(reblogged: Boolean) {
reblogButton!!.isChecked = reblogged
}
// This should only be called after setReblogged, in order to override the tint correctly.
private fun setRebloggingEnabled(enabled: Boolean, visibility: Status.Visibility) {
reblogButton!!.isEnabled = enabled && visibility !== Status.Visibility.PRIVATE
if (enabled) {
val inactiveId: Int
val activeId: Int
if (visibility === Status.Visibility.PRIVATE) {
inactiveId = R.drawable.ic_reblog_private_24dp
activeId = R.drawable.ic_reblog_private_active_24dp
} else {
inactiveId = R.drawable.ic_reblog_24dp
activeId = R.drawable.ic_reblog_active_24dp
}
reblogButton.setInactiveImage(inactiveId)
reblogButton.setActiveImage(activeId)
return
}
val disabledId: Int = if (visibility === Status.Visibility.DIRECT) {
R.drawable.ic_reblog_direct_24dp
} else {
R.drawable.ic_reblog_private_24dp
}
reblogButton.setInactiveImage(disabledId)
reblogButton.setActiveImage(disabledId)
}
protected fun setFavourited(favourited: Boolean) {
favouriteButton.isChecked = favourited
}
protected fun setBookmarked(bookmarked: Boolean) {
bookmarkButton.isChecked = bookmarked
}
private fun decodeBlurHash(blurhash: String): BitmapDrawable {
return decodeBlurHash(context, blurhash)
}
private fun loadImage(
imageView: MediaPreviewImageView,
previewUrl: String?,
focus: Attachment.Focus?,
blurhash: String?,
) {
val placeholder = blurhash?.let { decodeBlurHash(it) } ?: mediaPreviewUnloaded
if (TextUtils.isEmpty(previewUrl)) {
imageView.removeFocalPoint()
Glide.with(imageView)
.load(placeholder)
.centerInside()
.into(imageView)
return
}
if (focus != null) { // If there is a focal point for this attachment:
imageView.setFocalPoint(focus)
Glide.with(context)
.load(previewUrl)
.placeholder(placeholder)
.centerInside()
.addListener(imageView)
.into(imageView)
} else {
imageView.removeFocalPoint()
Glide.with(imageView)
.load(previewUrl)
.placeholder(placeholder)
.centerInside()
.into(imageView)
}
}
protected fun setMediaPreviews(
attachments: List<Attachment>,
sensitive: Boolean,
listener: StatusActionListener,
showingContent: Boolean,
useBlurhash: Boolean,
) {
mediaPreview.visibility = View.VISIBLE
mediaPreview.aspectRatios = attachments.aspectRatios()
mediaPreview.forEachIndexed { i: Int, imageView: MediaPreviewImageView, descriptionIndicator: TextView ->
val attachment = attachments[i]
val previewUrl = attachment.previewUrl
val description = attachment.description
val hasDescription = !TextUtils.isEmpty(description)
if (hasDescription) {
imageView.contentDescription = description
} else {
imageView.contentDescription = context.getString(R.string.action_view_media)
}
loadImage(
imageView,
if (showingContent) previewUrl else null,
attachment.meta?.focus,
if (useBlurhash) attachment.blurhash else null,
)
val type = attachment.type
if (showingContent && (type === Attachment.Type.VIDEO || type === Attachment.Type.GIFV)) {
imageView.foreground =
ContextCompat.getDrawable(context, R.drawable.play_indicator_overlay)
} else {
imageView.foreground = null
}
setAttachmentClickListener(imageView, listener, i, attachment, true)
if (sensitive) {
sensitiveMediaWarning.setText(R.string.post_sensitive_media_title)
} else {
sensitiveMediaWarning.setText(R.string.post_media_hidden_title)
}
sensitiveMediaWarning.visibility = if (showingContent) View.GONE else View.VISIBLE
sensitiveMediaShow.visibility = if (showingContent) View.VISIBLE else View.GONE
descriptionIndicator.visibility =
if (hasDescription && showingContent) View.VISIBLE else View.GONE
sensitiveMediaShow.setOnClickListener { v: View ->
if (bindingAdapterPosition != RecyclerView.NO_POSITION) {
listener.onContentHiddenChange(false, bindingAdapterPosition)
}
v.visibility = View.GONE
sensitiveMediaWarning.visibility = View.VISIBLE
descriptionIndicator.visibility = View.GONE
}
sensitiveMediaWarning.setOnClickListener { v: View ->
if (bindingAdapterPosition != RecyclerView.NO_POSITION) {
listener.onContentHiddenChange(true, bindingAdapterPosition)
}
v.visibility = View.GONE
sensitiveMediaShow.visibility = View.VISIBLE
descriptionIndicator.visibility = if (hasDescription) View.VISIBLE else View.GONE
}
}
}
private fun updateMediaLabel(index: Int, sensitive: Boolean, showingContent: Boolean) {
val label =
if (sensitive && !showingContent) context.getString(R.string.post_sensitive_media_title) else mediaDescriptions[index]
mediaLabels[index].text = label
}
protected fun setMediaLabel(
attachments: List<Attachment>,
sensitive: Boolean,
listener: StatusActionListener,
showingContent: Boolean,
) {
for (i in mediaLabels.indices) {
val mediaLabel = mediaLabels[i]
if (i < attachments.size) {
val attachment = attachments[i]
mediaLabel.visibility = View.VISIBLE
mediaDescriptions[i] = attachment.getFormattedDescription(context)
updateMediaLabel(i, sensitive, showingContent)
// Set the icon next to the label.
val drawableId = attachments[0].iconResource()
mediaLabel.setCompoundDrawablesWithIntrinsicBounds(drawableId, 0, 0, 0)
setAttachmentClickListener(mediaLabel, listener, i, attachment, false)
} else {
mediaLabel.visibility = View.GONE
}
}
}
private fun setAttachmentClickListener(
view: View,
listener: StatusActionListener,
index: Int,
attachment: Attachment,
animateTransition: Boolean,
) {
view.setOnClickListener { v: View? ->
val position = bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION } ?: return@setOnClickListener
if (sensitiveMediaWarning.visibility == View.VISIBLE) {
listener.onContentHiddenChange(true, bindingAdapterPosition)
} else {
listener.onViewMedia(position, index, if (animateTransition) v else null)
}
}
view.setOnLongClickListener {
val description = attachment.getFormattedDescription(view.context)
Toast.makeText(view.context, description, Toast.LENGTH_LONG).show()
true
}
}
protected fun hideSensitiveMediaWarning() {
sensitiveMediaWarning.hide()
sensitiveMediaShow.hide()
}
protected fun setupButtons(
listener: StatusActionListener,
accountId: String,
statusDisplayOptions: StatusDisplayOptions,
) {
val profileButtonClickListener = View.OnClickListener { listener.onViewAccount(accountId) }
avatar.setOnClickListener(profileButtonClickListener)
displayName.setOnClickListener(profileButtonClickListener)
replyButton.setOnClickListener {
val position = bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION } ?: return@setOnClickListener
listener.onReply(position)
}
reblogButton?.setEventListener { _: SparkButton?, buttonState: Boolean ->
// return true to play animation
val position = bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION } ?: return@setEventListener false
return@setEventListener if (statusDisplayOptions.confirmReblogs) {
showConfirmReblog(listener, buttonState, position)
false
} else {
listener.onReblog(!buttonState, position)
true
}
}
favouriteButton.setEventListener { _: SparkButton?, buttonState: Boolean ->
// return true to play animation
val position = bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION } ?: return@setEventListener true
return@setEventListener if (statusDisplayOptions.confirmFavourites) {
showConfirmFavourite(listener, buttonState, position)
false
} else {
listener.onFavourite(!buttonState, position)
true
}
}
bookmarkButton.setEventListener { _: SparkButton?, buttonState: Boolean ->
val position = bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION } ?: return@setEventListener true
listener.onBookmark(!buttonState, position)
true
}
moreButton.setOnClickListener { v: View? ->
val position = bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION } ?: return@setOnClickListener
listener.onMore(v!!, position)
}
/* Even though the content TextView is a child of the container, it won't respond to clicks
* if it contains URLSpans without also setting its listener. The surrounding spans will
* just eat the clicks instead of deferring to the parent listener, but WILL respond to a
* listener directly on the TextView, for whatever reason. */
val viewThreadListener = View.OnClickListener {
val position = bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION } ?: return@OnClickListener
listener.onViewThread(position)
}
content.setOnClickListener(viewThreadListener)
itemView.setOnClickListener(viewThreadListener)
}
private fun showConfirmReblog(
listener: StatusActionListener,
buttonState: Boolean,
position: Int,
) {
val popup = PopupMenu(context, reblogButton!!)
popup.inflate(R.menu.status_reblog)
val menu = popup.menu
if (buttonState) {
menu.findItem(R.id.menu_action_reblog).isVisible = false
} else {
menu.findItem(R.id.menu_action_unreblog).isVisible = false
}
popup.setOnMenuItemClickListener {
listener.onReblog(!buttonState, position)
if (!buttonState) {
reblogButton.playAnimation()
}
true
}
popup.show()
}
private fun showConfirmFavourite(
listener: StatusActionListener,
buttonState: Boolean,
position: Int,
) {
val popup = PopupMenu(context, favouriteButton)
popup.inflate(R.menu.status_favourite)
val menu = popup.menu
if (buttonState) {
menu.findItem(R.id.menu_action_favourite).isVisible = false
} else {
menu.findItem(R.id.menu_action_unfavourite).isVisible = false
}
popup.setOnMenuItemClickListener {
listener.onFavourite(!buttonState, position)
if (!buttonState) {
favouriteButton.playAnimation()
}
true
}
popup.show()
}
open fun setupWithStatus(
status: StatusViewData,
listener: StatusActionListener,
statusDisplayOptions: StatusDisplayOptions,
payloads: Any? = null,
) {
if (payloads == null) {
val actionable = status.actionable
setDisplayName(actionable.account.name, actionable.account.emojis, statusDisplayOptions)
setUsername(status.username)
setMetaData(status, statusDisplayOptions, listener)
setIsReply(actionable.inReplyToId != null)
setReplyCount(actionable.repliesCount, statusDisplayOptions.showStatsInline)
setAvatar(
actionable.account.avatar,
status.rebloggedAvatar,
actionable.account.bot,
statusDisplayOptions,
)
setReblogged(actionable.reblogged)
setFavourited(actionable.favourited)
setBookmarked(actionable.bookmarked)
val attachments = actionable.attachments
val sensitive = actionable.sensitive
if (statusDisplayOptions.mediaPreviewEnabled && hasPreviewableAttachment(attachments)) {
setMediaPreviews(
attachments,
sensitive,
listener,
status.isShowingContent,
statusDisplayOptions.useBlurhash,
)
if (attachments.isEmpty()) {
hideSensitiveMediaWarning()
}
// Hide the unused label.
for (mediaLabel in mediaLabels) {
mediaLabel.visibility = View.GONE
}
} else {
setMediaLabel(attachments, sensitive, listener, status.isShowingContent)
// Hide all unused views.
mediaPreview.visibility = View.GONE
hideSensitiveMediaWarning()
}
setupCard(
status,
status.isExpanded,
statusDisplayOptions.cardViewMode,
statusDisplayOptions,
listener,
)
setupButtons(
listener,
actionable.account.id,
statusDisplayOptions,
)
setRebloggingEnabled(actionable.rebloggingAllowed(), actionable.visibility)
setSpoilerAndContent(status, statusDisplayOptions, listener)
setDescriptionForStatus(status, statusDisplayOptions)
// Workaround for RecyclerView 1.0.0 / androidx.core 1.0.0
// RecyclerView tries to set AccessibilityDelegateCompat to null
// but ViewCompat code replaces is with the default one. RecyclerView never
// fetches another one from its delegate because it checks that it's set so we remove it
// and let RecyclerView ask for a new delegate.
itemView.accessibilityDelegate = null
} else {
if (payloads is List<*>) {
for (item in payloads) {
if (Key.KEY_CREATED == item) {
setMetaData(status, statusDisplayOptions, listener)
}
}
}
}
}
private fun setDescriptionForStatus(
status: StatusViewData,
statusDisplayOptions: StatusDisplayOptions,
) {
val (_, _, account, _, _, _, _, createdAt, editedAt, _, reblogsCount, favouritesCount, _, reblogged, favourited, bookmarked, sensitive, _, visibility) = status.actionable
val description = context.getString(
R.string.description_status,
account.displayName,
getContentWarningDescription(context, status),
if (TextUtils.isEmpty(status.spoilerText) || !sensitive || status.isExpanded) status.content else "",
getCreatedAtDescription(createdAt, statusDisplayOptions),
editedAt?.let { context.getString(R.string.description_post_edited) } ?: "",
getReblogDescription(context, status),
status.username,
if (reblogged) context.getString(R.string.description_post_reblogged) else "",
if (favourited) context.getString(R.string.description_post_favourited) else "",
if (bookmarked) context.getString(R.string.description_post_bookmarked) else "",
getMediaDescription(context, status),
visibility.description(context),
getFavsText(favouritesCount),
getReblogsText(reblogsCount),
status.actionable.poll?.let {
pollView.getPollDescription(
from(it),
statusDisplayOptions,
numberFormat,
absoluteTimeFormatter,
)
} ?: "",
)
itemView.contentDescription = description
}
protected fun getFavsText(count: Int): CharSequence {
if (count <= 0) return ""
val countString = numberFormat.format(count.toLong())
return HtmlCompat.fromHtml(
context.resources.getQuantityString(R.plurals.favs, count, countString),
HtmlCompat.FROM_HTML_MODE_LEGACY,
)
}
protected fun getReblogsText(count: Int): CharSequence {
if (count <= 0) return ""
val countString = numberFormat.format(count.toLong())
return HtmlCompat.fromHtml(
context.resources.getQuantityString(
R.plurals.reblogs,
count,
countString,
),
HtmlCompat.FROM_HTML_MODE_LEGACY,
)
}
protected fun setupCard(
status: StatusViewData,
expanded: Boolean,
cardViewMode: CardViewMode,
statusDisplayOptions: StatusDisplayOptions,
listener: StatusActionListener,
) {
cardView ?: return
val (_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, sensitive, _, _, attachments, _, _, _, _, _, poll, card) = status.actionable
if (cardViewMode !== CardViewMode.NONE && attachments.isEmpty() && poll == null && card != null &&
!TextUtils.isEmpty(card.url) &&
(!sensitive || expanded) &&
(!status.isCollapsible || !status.isCollapsed)
) {
cardView.visibility = View.VISIBLE
cardView.bind(card, status.actionable.sensitive, statusDisplayOptions) { target ->
if (card.kind == PreviewCardKind.PHOTO && card.embedUrl.isNotEmpty() && target == PreviewCardView.Target.IMAGE) {
context.startActivity(
newSingleImageIntent(context, card.embedUrl),
)
} else {
listener.onViewUrl(card.url)
}
}
} else {
cardView.visibility = View.GONE
}
}
open fun showStatusContent(show: Boolean) {
val visibility = if (show) View.VISIBLE else View.GONE
avatar.visibility = visibility
avatarInset.visibility = visibility
displayName.visibility = visibility
username.visibility = visibility
metaInfo.visibility = visibility
contentWarningDescription.visibility = visibility
contentWarningButton.visibility = visibility
content.visibility = visibility
cardView!!.visibility = visibility
mediaContainer.visibility = visibility
pollView.visibility = visibility
replyButton.visibility = visibility
reblogButton!!.visibility = visibility
favouriteButton.visibility = visibility
bookmarkButton.visibility = visibility
moreButton.visibility = visibility
}
companion object {
private const val TAG = "StatusBaseViewHolder"
@JvmStatic
protected fun hasPreviewableAttachment(attachments: List<Attachment>): Boolean {
for ((_, _, _, _, type) in attachments) {
if (type === Attachment.Type.AUDIO || type === Attachment.Type.UNKNOWN) {
return false
}
}
return true
}
private fun getReblogDescription(context: Context, status: StatusViewData): CharSequence {
return status.rebloggingStatus?.let {
context.getString(R.string.post_boosted_format, it.account.username)
} ?: ""
}
private fun getMediaDescription(context: Context, status: StatusViewData): CharSequence {
if (status.actionable.attachments.isEmpty()) return ""
val mediaDescriptions =
status.actionable.attachments.fold(StringBuilder()) { builder: StringBuilder, (_, _, _, _, _, description): Attachment ->
if (description == null) {
val placeholder =
context.getString(R.string.description_post_media_no_description_placeholder)
return@fold builder.append(placeholder)
} else {
builder.append("; ")
return@fold builder.append(description)
}
}
return context.getString(R.string.description_post_media, mediaDescriptions)
}
private fun getContentWarningDescription(context: Context, status: StatusViewData): CharSequence {
return if (!TextUtils.isEmpty(status.spoilerText)) {
context.getString(R.string.description_post_cw, status.spoilerText)
} else {
""
}
}
}
}

View File

@ -1,216 +0,0 @@
package app.pachli.adapter;
import android.content.Context;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.method.LinkMovementMethod;
import android.text.style.DynamicDrawableSpan;
import android.text.style.ImageSpan;
import android.view.View;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.recyclerview.widget.RecyclerView;
import java.text.DateFormat;
import java.util.Date;
import app.pachli.R;
import app.pachli.entity.Status;
import app.pachli.interfaces.StatusActionListener;
import app.pachli.util.CardViewMode;
import app.pachli.util.LinkHelper;
import app.pachli.util.NoUnderlineURLSpan;
import app.pachli.util.StatusDisplayOptions;
import app.pachli.viewdata.StatusViewData;
public class StatusDetailedViewHolder extends StatusBaseViewHolder {
private final TextView reblogs;
private final TextView favourites;
private final View infoDivider;
private static final DateFormat dateFormat = DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.SHORT);
public StatusDetailedViewHolder(@NonNull View view) {
super(view);
reblogs = view.findViewById(R.id.status_reblogs);
favourites = view.findViewById(R.id.status_favourites);
infoDivider = view.findViewById(R.id.status_info_divider);
}
@Override
protected void setMetaData(@NonNull StatusViewData statusViewData, @NonNull StatusDisplayOptions statusDisplayOptions, @NonNull StatusActionListener listener) {
Status status = statusViewData.getActionable();
Status.Visibility visibility = status.getVisibility();
Context context = metaInfo.getContext();
Drawable visibilityIcon = getVisibilityIcon(visibility);
CharSequence visibilityString = getVisibilityDescription(context, visibility);
SpannableStringBuilder sb = new SpannableStringBuilder(visibilityString);
if (visibilityIcon != null) {
ImageSpan visibilityIconSpan = new ImageSpan(
visibilityIcon,
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ? DynamicDrawableSpan.ALIGN_CENTER : DynamicDrawableSpan.ALIGN_BASELINE
);
sb.setSpan(visibilityIconSpan, 0, visibilityString.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
String metadataJoiner = context.getString(R.string.metadata_joiner);
Date createdAt = status.getCreatedAt();
if (createdAt != null) {
sb.append(" ");
sb.append(dateFormat.format(createdAt));
}
Date editedAt = status.getEditedAt();
if (editedAt != null) {
String editedAtString = context.getString(R.string.post_edited, dateFormat.format(editedAt));
sb.append(metadataJoiner);
int spanStart = sb.length();
int spanEnd = spanStart + editedAtString.length();
sb.append(editedAtString);
if (statusViewData.getStatus().getEditedAt() != null) {
NoUnderlineURLSpan editedClickSpan = new NoUnderlineURLSpan("") {
@Override
public void onClick(@NonNull View view) {
listener.onShowEdits(getBindingAdapterPosition());
}
};
sb.setSpan(editedClickSpan, spanStart, spanEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
}
Status.Application app = status.getApplication();
if (app != null) {
sb.append(metadataJoiner);
if (app.getWebsite() != null) {
CharSequence text = LinkHelper.createClickableText(app.getName(), app.getWebsite());
sb.append(text);
} else {
sb.append(app.getName());
}
}
metaInfo.setMovementMethod(LinkMovementMethod.getInstance());
metaInfo.setText(sb);
}
private void setReblogAndFavCount(int reblogCount, int favCount, @NonNull StatusActionListener listener) {
if (reblogCount > 0) {
reblogs.setText(getReblogsText(reblogs.getContext(), reblogCount));
reblogs.setVisibility(View.VISIBLE);
} else {
reblogs.setVisibility(View.GONE);
}
if (favCount > 0) {
favourites.setText(getFavsText(favourites.getContext(), favCount));
favourites.setVisibility(View.VISIBLE);
} else {
favourites.setVisibility(View.GONE);
}
if (reblogs.getVisibility() == View.GONE && favourites.getVisibility() == View.GONE) {
infoDivider.setVisibility(View.GONE);
} else {
infoDivider.setVisibility(View.VISIBLE);
}
reblogs.setOnClickListener(v -> {
int position = getBindingAdapterPosition();
if (position != RecyclerView.NO_POSITION) {
listener.onShowReblogs(position);
}
});
favourites.setOnClickListener(v -> {
int position = getBindingAdapterPosition();
if (position != RecyclerView.NO_POSITION) {
listener.onShowFavs(position);
}
});
}
@Override
public void setupWithStatus(@NonNull final StatusViewData status,
@NonNull final StatusActionListener listener,
@NonNull StatusDisplayOptions statusDisplayOptions,
@Nullable Object payloads) {
// We never collapse statuses in the detail view
StatusViewData uncollapsedStatus = (status.isCollapsible() && status.isCollapsed()) ?
status.copyWithCollapsed(false) :
status;
super.setupWithStatus(uncollapsedStatus, listener, statusDisplayOptions, payloads);
setupCard(uncollapsedStatus, status.isExpanded(), CardViewMode.FULL_WIDTH, statusDisplayOptions, listener); // Always show card for detailed status
if (payloads == null) {
Status actionable = uncollapsedStatus.getActionable();
if (!statusDisplayOptions.hideStats()) {
setReblogAndFavCount(actionable.getReblogsCount(),
actionable.getFavouritesCount(), listener);
} else {
hideQuantitativeStats();
}
}
}
private @Nullable Drawable getVisibilityIcon(@Nullable Status.Visibility visibility) {
if (visibility == null) {
return null;
}
int visibilityIcon;
switch (visibility) {
case PUBLIC -> visibilityIcon = R.drawable.ic_public_24dp;
case UNLISTED -> visibilityIcon = R.drawable.ic_lock_open_24dp;
case PRIVATE -> visibilityIcon = R.drawable.ic_lock_outline_24dp;
case DIRECT -> visibilityIcon = R.drawable.ic_email_24dp;
default -> {
return null;
}
}
final Drawable visibilityDrawable = AppCompatResources.getDrawable(
this.metaInfo.getContext(), visibilityIcon
);
if (visibilityDrawable == null) {
return null;
}
final int size = (int) this.metaInfo.getTextSize();
visibilityDrawable.setBounds(
0,
0,
size,
size
);
visibilityDrawable.setTint(this.metaInfo.getCurrentTextColor());
return visibilityDrawable;
}
private void hideQuantitativeStats() {
reblogs.setVisibility(View.GONE);
favourites.setVisibility(View.GONE);
infoDivider.setVisibility(View.GONE);
}
}

View File

@ -0,0 +1,142 @@
package app.pachli.adapter
import android.os.Build
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.method.LinkMovementMethod
import android.text.style.DynamicDrawableSpan
import android.text.style.ImageSpan
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import app.pachli.R
import app.pachli.databinding.ItemStatusDetailedBinding
import app.pachli.entity.description
import app.pachli.entity.icon
import app.pachli.interfaces.StatusActionListener
import app.pachli.util.CardViewMode
import app.pachli.util.NoUnderlineURLSpan
import app.pachli.util.StatusDisplayOptions
import app.pachli.util.createClickableText
import app.pachli.util.hide
import app.pachli.util.show
import app.pachli.viewdata.StatusViewData
import java.text.DateFormat
class StatusDetailedViewHolder(
private val binding: ItemStatusDetailedBinding,
) : StatusBaseViewHolder(binding.root) {
override fun setMetaData(
statusViewData: StatusViewData,
statusDisplayOptions: StatusDisplayOptions,
listener: StatusActionListener,
) {
val (_, _, _, _, _, _, _, createdAt, editedAt, _, _, _, _, _, _, _, _, _, visibility, _, _, _, app) = statusViewData.actionable
val visibilityIcon = visibility.icon(metaInfo)
val visibilityString = visibility.description(context)
val sb = SpannableStringBuilder(visibilityString)
visibilityIcon?.also {
val alignment = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) DynamicDrawableSpan.ALIGN_CENTER else DynamicDrawableSpan.ALIGN_BASELINE
val visibilityIconSpan = ImageSpan(it, alignment)
sb.setSpan(visibilityIconSpan, 0, visibilityString.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
val metadataJoiner = context.getString(R.string.metadata_joiner)
sb.append(" ")
sb.append(dateFormat.format(createdAt))
editedAt?.also {
val editedAtString = context.getString(R.string.post_edited, dateFormat.format(it))
sb.append(metadataJoiner)
val spanStart = sb.length
val spanEnd = spanStart + editedAtString.length
sb.append(editedAtString)
statusViewData.status.editedAt?.also {
val editedClickSpan: NoUnderlineURLSpan = object : NoUnderlineURLSpan("") {
override fun onClick(view: View) {
listener.onShowEdits(bindingAdapterPosition)
}
}
sb.setSpan(editedClickSpan, spanStart, spanEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
app?.also { application ->
val (name, website) = application
sb.append(metadataJoiner)
website?.also { sb.append(createClickableText(name, it)) ?: sb.append(name) }
}
metaInfo.movementMethod = LinkMovementMethod.getInstance()
metaInfo.text = sb
}
private fun setReblogAndFavCount(
reblogCount: Int,
favCount: Int,
listener: StatusActionListener,
) {
if (reblogCount > 0) {
binding.statusReblogs.text = getReblogsText(reblogCount)
binding.statusReblogs.show()
} else {
binding.statusReblogs.hide()
}
if (favCount > 0) {
binding.statusFavourites.text = getFavsText(favCount)
binding.statusFavourites.show()
} else {
binding.statusFavourites.hide()
}
if (binding.statusReblogs.visibility == View.GONE && binding.statusFavourites.visibility == View.GONE) {
binding.statusInfoDivider.hide()
} else {
binding.statusInfoDivider.show()
}
binding.statusReblogs.setOnClickListener {
val position = bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION } ?: return@setOnClickListener
listener.onShowReblogs(position)
}
binding.statusFavourites.setOnClickListener {
val position = bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION } ?: return@setOnClickListener
listener.onShowFavs(position)
}
}
override fun setupWithStatus(
status: StatusViewData,
listener: StatusActionListener,
statusDisplayOptions: StatusDisplayOptions,
payloads: Any?,
) {
// We never collapse statuses in the detail view
val uncollapsedStatus =
if (status.isCollapsible && status.isCollapsed) status.copyWithCollapsed(false) else status
super.setupWithStatus(uncollapsedStatus, listener, statusDisplayOptions, payloads)
setupCard(
uncollapsedStatus,
status.isExpanded,
CardViewMode.FULL_WIDTH,
statusDisplayOptions,
listener,
) // Always show card for detailed status
if (payloads == null) {
val (_, _, _, _, _, _, _, _, _, _, reblogsCount, favouritesCount) = uncollapsedStatus.actionable
if (!statusDisplayOptions.hideStats) {
setReblogAndFavCount(reblogsCount, favouritesCount, listener)
} else {
hideQuantitativeStats()
}
}
}
private fun hideQuantitativeStats() {
binding.statusReblogs.hide()
binding.statusFavourites.hide()
binding.statusInfoDivider.hide()
}
companion object {
private val dateFormat =
DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.SHORT)
}
}

View File

@ -1,169 +0,0 @@
/* Copyright 2017 Andrew Dawson
*
* This file is a part of Pachli.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package app.pachli.adapter;
import android.content.Context;
import android.text.InputFilter;
import android.text.TextUtils;
import android.view.View;
import android.widget.Button;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import java.util.List;
import app.pachli.R;
import app.pachli.entity.Emoji;
import app.pachli.entity.Filter;
import app.pachli.entity.Status;
import app.pachli.interfaces.StatusActionListener;
import app.pachli.util.CustomEmojiHelper;
import app.pachli.util.NumberUtils;
import app.pachli.util.SmartLengthInputFilter;
import app.pachli.util.StatusDisplayOptions;
import app.pachli.util.StringUtils;
import app.pachli.viewdata.StatusViewData;
import at.connyduck.sparkbutton.helpers.Utils;
public class StatusViewHolder extends StatusBaseViewHolder {
private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE};
private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0];
private final TextView statusInfo;
private final Button contentCollapseButton;
private final TextView favouritedCountLabel;
private final TextView reblogsCountLabel;
public StatusViewHolder(@NonNull View itemView) {
super(itemView);
statusInfo = itemView.findViewById(R.id.status_info);
contentCollapseButton = itemView.findViewById(R.id.button_toggle_content);
favouritedCountLabel = itemView.findViewById(R.id.status_favourites_count);
reblogsCountLabel = itemView.findViewById(R.id.status_insets);
}
@Override
public void setupWithStatus(@NonNull StatusViewData status,
@NonNull final StatusActionListener listener,
@NonNull StatusDisplayOptions statusDisplayOptions,
@Nullable Object payloads) {
if (payloads == null) {
boolean sensitive = !TextUtils.isEmpty(status.getActionable().getSpoilerText());
boolean expanded = status.isExpanded();
setupCollapsedState(sensitive, expanded, status, listener);
Status reblogging = status.getRebloggingStatus();
if (reblogging == null || status.getFilterAction() == Filter.Action.WARN) {
hideStatusInfo();
} else {
String rebloggedByDisplayName = reblogging.getAccount().getName();
setRebloggedByDisplayName(rebloggedByDisplayName,
reblogging.getAccount().getEmojis(), statusDisplayOptions);
statusInfo.setOnClickListener(v -> listener.onOpenReblog(getBindingAdapterPosition()));
}
}
reblogsCountLabel.setVisibility(statusDisplayOptions.showStatsInline() ? View.VISIBLE : View.INVISIBLE);
favouritedCountLabel.setVisibility(statusDisplayOptions.showStatsInline() ? View.VISIBLE : View.INVISIBLE);
setFavouritedCount(status.getActionable().getFavouritesCount());
setReblogsCount(status.getActionable().getReblogsCount());
super.setupWithStatus(status, listener, statusDisplayOptions, payloads);
}
private void setRebloggedByDisplayName(@NonNull final CharSequence name,
final List<Emoji> accountEmoji,
@NonNull final StatusDisplayOptions statusDisplayOptions) {
Context context = statusInfo.getContext();
CharSequence wrappedName = StringUtils.unicodeWrap(name);
CharSequence boostedText = context.getString(R.string.post_boosted_format, wrappedName);
CharSequence emojifiedText = CustomEmojiHelper.emojify(
boostedText, accountEmoji, statusInfo, statusDisplayOptions.animateEmojis()
);
statusInfo.setText(emojifiedText);
statusInfo.setVisibility(View.VISIBLE);
}
// don't use this on the same ViewHolder as setRebloggedByDisplayName, will cause recycling issues as paddings are changed
protected void setPollInfo(final boolean ownPoll) {
statusInfo.setText(ownPoll ? R.string.poll_ended_created : R.string.poll_ended_voted);
statusInfo.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_poll_24dp, 0, 0, 0);
statusInfo.setCompoundDrawablePadding(Utils.dpToPx(statusInfo.getContext(), 10));
statusInfo.setPaddingRelative(Utils.dpToPx(statusInfo.getContext(), 28), 0, 0, 0);
statusInfo.setVisibility(View.VISIBLE);
}
protected void setReblogsCount(int reblogsCount) {
reblogsCountLabel.setText(NumberUtils.formatNumber(reblogsCount, 1000));
}
protected void setFavouritedCount(int favouritedCount) {
favouritedCountLabel.setText(NumberUtils.formatNumber(favouritedCount, 1000));
}
protected void hideStatusInfo() {
statusInfo.setVisibility(View.GONE);
}
private void setupCollapsedState(boolean sensitive,
boolean expanded,
@NonNull final StatusViewData status,
@NonNull final StatusActionListener listener) {
/* input filter for TextViews have to be set before text */
if (status.isCollapsible() && (!sensitive || expanded)) {
contentCollapseButton.setOnClickListener(view -> {
int position = getBindingAdapterPosition();
if (position != RecyclerView.NO_POSITION)
listener.onContentCollapsedChange(!status.isCollapsed(), position);
});
contentCollapseButton.setVisibility(View.VISIBLE);
if (status.isCollapsed()) {
contentCollapseButton.setText(R.string.post_content_warning_show_more);
content.setFilters(COLLAPSE_INPUT_FILTER);
} else {
contentCollapseButton.setText(R.string.post_content_warning_show_less);
content.setFilters(NO_INPUT_FILTER);
}
} else {
contentCollapseButton.setVisibility(View.GONE);
content.setFilters(NO_INPUT_FILTER);
}
}
public void showStatusContent(boolean show) {
super.showStatusContent(show);
contentCollapseButton.setVisibility(show ? View.VISIBLE : View.GONE);
}
@Override
protected void toggleExpandedState(boolean sensitive,
boolean expanded,
@NonNull StatusViewData status,
@NonNull StatusDisplayOptions statusDisplayOptions,
@NonNull final StatusActionListener listener) {
setupCollapsedState(sensitive, expanded, status, listener);
super.toggleExpandedState(sensitive, expanded, status, statusDisplayOptions, listener);
}
}

View File

@ -0,0 +1,160 @@
/* Copyright 2017 Andrew Dawson
*
* This file is a part of Pachli.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>.
*/
package app.pachli.adapter
import android.text.InputFilter
import android.text.TextUtils
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import app.pachli.R
import app.pachli.databinding.ItemStatusBinding
import app.pachli.entity.Emoji
import app.pachli.entity.Filter
import app.pachli.interfaces.StatusActionListener
import app.pachli.util.SmartLengthInputFilter
import app.pachli.util.StatusDisplayOptions
import app.pachli.util.emojify
import app.pachli.util.formatNumber
import app.pachli.util.hide
import app.pachli.util.show
import app.pachli.util.unicodeWrap
import app.pachli.util.visible
import app.pachli.viewdata.StatusViewData
import at.connyduck.sparkbutton.helpers.Utils
open class StatusViewHolder(
private val binding: ItemStatusBinding,
root: View? = null,
) : StatusBaseViewHolder(root ?: binding.root) {
override fun setupWithStatus(
status: StatusViewData,
listener: StatusActionListener,
statusDisplayOptions: StatusDisplayOptions,
payloads: Any?,
) = with(binding) {
if (payloads == null) {
val sensitive = !TextUtils.isEmpty(status.actionable.spoilerText)
val expanded = status.isExpanded
setupCollapsedState(sensitive, expanded, status, listener)
val reblogging = status.rebloggingStatus
if (reblogging == null || status.filterAction === Filter.Action.WARN) {
statusInfo.hide()
} else {
val rebloggedByDisplayName = reblogging.account.name
setRebloggedByDisplayName(
rebloggedByDisplayName,
reblogging.account.emojis,
statusDisplayOptions,
)
statusInfo.setOnClickListener {
listener.onOpenReblog(bindingAdapterPosition)
}
}
}
statusReblogsCount.visible(statusDisplayOptions.showStatsInline)
statusFavouritesCount.visible(statusDisplayOptions.showStatsInline)
setFavouritedCount(status.actionable.favouritesCount)
setReblogsCount(status.actionable.reblogsCount)
super.setupWithStatus(status, listener, statusDisplayOptions, payloads)
}
private fun setRebloggedByDisplayName(
name: CharSequence,
accountEmoji: List<Emoji>?,
statusDisplayOptions: StatusDisplayOptions,
) = with(binding) {
val wrappedName: CharSequence = name.unicodeWrap()
val boostedText: CharSequence = context.getString(R.string.post_boosted_format, wrappedName)
val emojifiedText =
boostedText.emojify(accountEmoji, statusInfo, statusDisplayOptions.animateEmojis)
statusInfo.text = emojifiedText
statusInfo.show()
}
// don't use this on the same ViewHolder as setRebloggedByDisplayName, will cause recycling issues as paddings are changed
protected fun setPollInfo(ownPoll: Boolean) = with(binding) {
statusInfo.setText(if (ownPoll) R.string.poll_ended_created else R.string.poll_ended_voted)
statusInfo.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_poll_24dp, 0, 0, 0)
statusInfo.compoundDrawablePadding =
Utils.dpToPx(context, 10)
statusInfo.setPaddingRelative(Utils.dpToPx(context, 28), 0, 0, 0)
statusInfo.show()
}
private fun setReblogsCount(reblogsCount: Int) = with(binding) {
statusReblogsCount.text = formatNumber(reblogsCount.toLong(), 1000)
}
private fun setFavouritedCount(favouritedCount: Int) = with(binding) {
statusFavouritesCount.text = formatNumber(favouritedCount.toLong(), 1000)
}
protected fun hideStatusInfo() = with(binding) {
statusInfo.hide()
}
private fun setupCollapsedState(
sensitive: Boolean,
expanded: Boolean,
status: StatusViewData,
listener: StatusActionListener,
) = with(binding) {
/* input filter for TextViews have to be set before text */
if (status.isCollapsible && (!sensitive || expanded)) {
buttonToggleContent.setOnClickListener {
val position = bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION } ?: return@setOnClickListener
listener.onContentCollapsedChange(
!status.isCollapsed,
position,
)
}
buttonToggleContent.show()
if (status.isCollapsed) {
buttonToggleContent.setText(R.string.post_content_warning_show_more)
content.filters = COLLAPSE_INPUT_FILTER
} else {
buttonToggleContent.setText(R.string.post_content_warning_show_less)
content.filters = NO_INPUT_FILTER
}
} else {
buttonToggleContent.hide()
content.filters = NO_INPUT_FILTER
}
}
override fun showStatusContent(show: Boolean) = with(binding) {
super.showStatusContent(show)
buttonToggleContent.visibility = if (show) View.VISIBLE else View.GONE
}
override fun toggleExpandedState(
sensitive: Boolean,
expanded: Boolean,
status: StatusViewData,
statusDisplayOptions: StatusDisplayOptions,
listener: StatusActionListener,
) {
setupCollapsedState(sensitive, expanded, status, listener)
super.toggleExpandedState(sensitive, expanded, status, statusDisplayOptions, listener)
}
companion object {
private val COLLAPSE_INPUT_FILTER = arrayOf<InputFilter>(SmartLengthInputFilter)
private val NO_INPUT_FILTER = arrayOfNulls<InputFilter>(0)
}
}

View File

@ -1,178 +0,0 @@
/* Copyright 2017 Andrew Dawson
*
* This file is a part of Pachli.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package app.pachli.components.conversation;
import android.content.Context;
import android.text.InputFilter;
import android.text.TextUtils;
import android.view.View;
import android.widget.Button;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import java.util.List;
import app.pachli.R;
import app.pachli.adapter.StatusBaseViewHolder;
import app.pachli.entity.Attachment;
import app.pachli.entity.Status;
import app.pachli.entity.TimelineAccount;
import app.pachli.interfaces.StatusActionListener;
import app.pachli.util.ImageLoadingHelper;
import app.pachli.util.SmartLengthInputFilter;
import app.pachli.util.StatusDisplayOptions;
import app.pachli.viewdata.StatusViewData;
public class ConversationViewHolder extends StatusBaseViewHolder {
private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE};
private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0];
private final TextView conversationNameTextView;
private final Button contentCollapseButton;
@NonNull
private final ImageView[] avatars;
private final StatusDisplayOptions statusDisplayOptions;
private final StatusActionListener listener;
ConversationViewHolder(@NonNull View itemView,
StatusDisplayOptions statusDisplayOptions,
StatusActionListener listener) {
super(itemView);
conversationNameTextView = itemView.findViewById(R.id.conversation_name);
contentCollapseButton = itemView.findViewById(R.id.button_toggle_content);
avatars = new ImageView[]{
avatar,
itemView.findViewById(R.id.status_avatar_1),
itemView.findViewById(R.id.status_avatar_2)
};
this.statusDisplayOptions = statusDisplayOptions;
this.listener = listener;
}
void setupWithConversation(
@NonNull ConversationViewData conversation,
@Nullable Object payloads
) {
StatusViewData statusViewData = conversation.getLastStatus();
Status status = statusViewData.getStatus();
if (payloads == null) {
TimelineAccount account = status.getAccount();
setupCollapsedState(statusViewData.isCollapsible(), statusViewData.isCollapsed(), statusViewData.isExpanded(), statusViewData.getSpoilerText(), listener);
setDisplayName(account.getName(), account.getEmojis(), statusDisplayOptions);
setUsername(account.getUsername());
setMetaData(statusViewData, statusDisplayOptions, listener);
setIsReply(status.getInReplyToId() != null);
setFavourited(status.getFavourited());
setBookmarked(status.getBookmarked());
List<Attachment> attachments = status.getAttachments();
boolean sensitive = status.getSensitive();
if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) {
setMediaPreviews(attachments, sensitive, listener, statusViewData.isShowingContent(),
statusDisplayOptions.useBlurhash());
if (attachments.size() == 0) {
hideSensitiveMediaWarning();
}
// Hide the unused label.
for (TextView mediaLabel : mediaLabels) {
mediaLabel.setVisibility(View.GONE);
}
} else {
setMediaLabel(attachments, sensitive, listener, statusViewData.isShowingContent());
// Hide all unused views.
mediaPreview.setVisibility(View.GONE);
hideSensitiveMediaWarning();
}
setupButtons(listener, account.getId(), statusViewData.getContent().toString(),
statusDisplayOptions);
setSpoilerAndContent(statusViewData, statusDisplayOptions, listener);
setConversationName(conversation.getAccounts());
setAvatars(conversation.getAccounts());
} else {
if (payloads instanceof List) {
for (Object item : (List<?>) payloads) {
if (Key.KEY_CREATED.equals(item)) {
setMetaData(statusViewData, statusDisplayOptions, listener);
}
}
}
}
}
private void setConversationName(@NonNull List<ConversationAccountEntity> accounts) {
Context context = conversationNameTextView.getContext();
String conversationName = "";
if (accounts.size() == 1) {
conversationName = context.getString(R.string.conversation_1_recipients, accounts.get(0).getUsername());
} else if (accounts.size() == 2) {
conversationName = context.getString(R.string.conversation_2_recipients, accounts.get(0).getUsername(), accounts.get(1).getUsername());
} else if (accounts.size() > 2) {
conversationName = context.getString(R.string.conversation_more_recipients, accounts.get(0).getUsername(), accounts.get(1).getUsername(), accounts.size() - 2);
}
conversationNameTextView.setText(conversationName);
}
private void setAvatars(@NonNull List<ConversationAccountEntity> accounts) {
for (int i = 0; i < avatars.length; i++) {
ImageView avatarView = avatars[i];
if (i < accounts.size()) {
ImageLoadingHelper.loadAvatar(accounts.get(i).getAvatar(), avatarView,
avatarRadius48dp, statusDisplayOptions.animateAvatars(), null);
avatarView.setVisibility(View.VISIBLE);
} else {
avatarView.setVisibility(View.GONE);
}
}
}
private void setupCollapsedState(boolean collapsible, boolean collapsed, boolean expanded, String spoilerText, @NonNull final StatusActionListener listener) {
/* input filter for TextViews have to be set before text */
if (collapsible && (expanded || TextUtils.isEmpty(spoilerText))) {
contentCollapseButton.setOnClickListener(view -> {
int position = getBindingAdapterPosition();
if (position != RecyclerView.NO_POSITION)
listener.onContentCollapsedChange(!collapsed, position);
});
contentCollapseButton.setVisibility(View.VISIBLE);
if (collapsed) {
contentCollapseButton.setText(R.string.post_content_warning_show_more);
content.setFilters(COLLAPSE_INPUT_FILTER);
} else {
contentCollapseButton.setText(R.string.post_content_warning_show_less);
content.setFilters(NO_INPUT_FILTER);
}
} else {
contentCollapseButton.setVisibility(View.GONE);
content.setFilters(NO_INPUT_FILTER);
}
}
}

View File

@ -0,0 +1,179 @@
/* Copyright 2017 Andrew Dawson
*
* This file is a part of Pachli.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>.
*/
package app.pachli.components.conversation
import android.text.InputFilter
import android.text.TextUtils
import android.view.View
import android.widget.Button
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import app.pachli.R
import app.pachli.adapter.StatusBaseViewHolder
import app.pachli.interfaces.StatusActionListener
import app.pachli.util.SmartLengthInputFilter
import app.pachli.util.StatusDisplayOptions
import app.pachli.util.hide
import app.pachli.util.loadAvatar
import app.pachli.util.show
class ConversationViewHolder internal constructor(
itemView: View,
private val statusDisplayOptions: StatusDisplayOptions,
private val listener: StatusActionListener,
) : StatusBaseViewHolder(itemView) {
private val conversationNameTextView: TextView
private val contentCollapseButton: Button
private val avatars: Array<ImageView>
init {
conversationNameTextView = itemView.findViewById(R.id.conversation_name)
contentCollapseButton = itemView.findViewById(R.id.button_toggle_content)
avatars = arrayOf(
avatar,
itemView.findViewById(R.id.status_avatar_1),
itemView.findViewById(R.id.status_avatar_2),
)
}
fun setupWithConversation(
conversation: ConversationViewData,
payloads: Any?,
) {
val statusViewData = conversation.lastStatus
val (_, _, account, inReplyToId, _, _, _, _, _, _, _, _, _, _, favourited, bookmarked, sensitive, _, _, attachments) = statusViewData.status
if (payloads == null) {
setupCollapsedState(
statusViewData.isCollapsible,
statusViewData.isCollapsed,
statusViewData.isExpanded,
statusViewData.spoilerText,
listener,
)
setDisplayName(account.name, account.emojis, statusDisplayOptions)
setUsername(account.username)
setMetaData(statusViewData, statusDisplayOptions, listener)
setIsReply(inReplyToId != null)
setFavourited(favourited)
setBookmarked(bookmarked)
if (statusDisplayOptions.mediaPreviewEnabled && hasPreviewableAttachment(attachments)) {
setMediaPreviews(
attachments,
sensitive,
listener,
statusViewData.isShowingContent,
statusDisplayOptions.useBlurhash,
)
if (attachments.isEmpty()) {
hideSensitiveMediaWarning()
}
// Hide the unused label.
for (mediaLabel in mediaLabels) {
mediaLabel.visibility = View.GONE
}
} else {
setMediaLabel(attachments, sensitive, listener, statusViewData.isShowingContent)
// Hide all unused views.
mediaPreview.visibility = View.GONE
hideSensitiveMediaWarning()
}
setupButtons(
listener,
account.id,
statusDisplayOptions,
)
setSpoilerAndContent(statusViewData, statusDisplayOptions, listener)
setConversationName(conversation.accounts)
setAvatars(conversation.accounts)
} else {
if (payloads is List<*>) {
for (item in payloads) {
if (Key.KEY_CREATED == item) {
setMetaData(statusViewData, statusDisplayOptions, listener)
}
}
}
}
}
private fun setConversationName(accounts: List<ConversationAccountEntity>) {
conversationNameTextView.text = when (accounts.size) {
1 -> context.getString(
R.string.conversation_1_recipients,
accounts[0].username,
)
2 -> context.getString(
R.string.conversation_2_recipients,
accounts[0].username,
accounts[1].username,
)
else -> context.getString(
R.string.conversation_more_recipients,
accounts[0].username,
accounts[1].username,
accounts.size - 2,
)
}
}
private fun setAvatars(accounts: List<ConversationAccountEntity>) {
avatars.withIndex().forEach { views ->
accounts.getOrNull(views.index)?.also { account ->
loadAvatar(
account.avatar,
views.value,
avatarRadius48dp,
statusDisplayOptions.animateAvatars,
)
views.value.show()
} ?: views.value.hide()
}
}
private fun setupCollapsedState(
collapsible: Boolean,
collapsed: Boolean,
expanded: Boolean,
spoilerText: String,
listener: StatusActionListener,
) {
/* input filter for TextViews have to be set before text */
if (collapsible && (expanded || TextUtils.isEmpty(spoilerText))) {
contentCollapseButton.setOnClickListener {
val position = bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION } ?: return@setOnClickListener
listener.onContentCollapsedChange(!collapsed, position)
}
contentCollapseButton.show()
if (collapsed) {
contentCollapseButton.setText(R.string.post_content_warning_show_more)
content.filters = COLLAPSE_INPUT_FILTER
} else {
contentCollapseButton.setText(R.string.post_content_warning_show_less)
content.filters = NO_INPUT_FILTER
}
} else {
contentCollapseButton.visibility = View.GONE
content.filters = NO_INPUT_FILTER
}
}
companion object {
private val COLLAPSE_INPUT_FILTER = arrayOf<InputFilter>(SmartLengthInputFilter)
private val NO_INPUT_FILTER = arrayOfNulls<InputFilter>(0)
}
}

View File

@ -145,7 +145,7 @@ class NotificationsPagingAdapter(
)
}
NotificationViewKind.STATUS_FILTERED -> {
StatusViewHolder(
FilterableStatusViewHolder(
ItemStatusWrapperBinding.inflate(inflater, parent, false),
statusActionListener,
accountId,

View File

@ -17,18 +17,20 @@
package app.pachli.components.notifications
import androidx.viewbinding.ViewBinding
import app.pachli.adapter.FilterableStatusViewHolder
import app.pachli.adapter.StatusViewHolder
import app.pachli.databinding.ItemStatusBinding
import app.pachli.databinding.ItemStatusWrapperBinding
import app.pachli.entity.Notification
import app.pachli.interfaces.StatusActionListener
import app.pachli.util.StatusDisplayOptions
import app.pachli.viewdata.NotificationViewData
internal class StatusViewHolder(
binding: ViewBinding,
binding: ItemStatusBinding,
private val statusActionListener: StatusActionListener,
private val accountId: String,
) : NotificationsPagingAdapter.ViewHolder, StatusViewHolder(binding.root) {
) : NotificationsPagingAdapter.ViewHolder, StatusViewHolder(binding) {
override fun bind(
viewData: NotificationViewData,
@ -58,3 +60,38 @@ internal class StatusViewHolder(
}
}
}
class FilterableStatusViewHolder(
binding: ItemStatusWrapperBinding,
private val statusActionListener: StatusActionListener,
private val accountId: String,
) : NotificationsPagingAdapter.ViewHolder, FilterableStatusViewHolder(binding) {
// Note: Identical to bind() in StatusViewHolder above
override fun bind(
viewData: NotificationViewData,
payloads: List<*>?,
statusDisplayOptions: StatusDisplayOptions,
) {
val statusViewData = viewData.statusViewData
if (statusViewData == null) {
// Hide null statuses. Shouldn't happen according to the spec, but some servers
// have been seen to do this (https://github.com/tuskyapp/Tusky/issues/2252)
showStatusContent(false)
} else {
if (payloads.isNullOrEmpty()) {
showStatusContent(true)
}
setupWithStatus(
statusViewData,
statusActionListener,
statusDisplayOptions,
payloads?.firstOrNull(),
)
}
if (viewData.type == Notification.Type.POLL) {
setPollInfo(accountId == viewData.account.id)
} else {
hideStatusInfo()
}
}
}

View File

@ -42,7 +42,7 @@ import app.pachli.viewdata.PollViewData
import app.pachli.viewdata.StatusViewData
import java.util.Date
class StatusViewHolder(
open class StatusViewHolder(
private val binding: ItemReportStatusBinding,
private val statusDisplayOptions: StatusDisplayOptions,
private val viewState: StatusViewState,

View File

@ -19,8 +19,8 @@ import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import app.pachli.R
import app.pachli.adapter.StatusViewHolder
import app.pachli.databinding.ItemStatusBinding
import app.pachli.interfaces.StatusActionListener
import app.pachli.util.StatusDisplayOptions
import app.pachli.viewdata.StatusViewData
@ -31,9 +31,9 @@ class SearchStatusesAdapter(
) : PagingDataAdapter<StatusViewData, StatusViewHolder>(STATUS_COMPARATOR) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StatusViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_status, parent, false)
return StatusViewHolder(view)
return StatusViewHolder(
ItemStatusBinding.inflate(LayoutInflater.from(parent.context), parent, false),
)
}
override fun onBindViewHolder(holder: StatusViewHolder, position: Int) {

View File

@ -21,8 +21,11 @@ import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import app.pachli.R
import app.pachli.adapter.FilterableStatusViewHolder
import app.pachli.adapter.StatusBaseViewHolder
import app.pachli.adapter.StatusViewHolder
import app.pachli.databinding.ItemStatusBinding
import app.pachli.databinding.ItemStatusWrapperBinding
import app.pachli.entity.Filter
import app.pachli.interfaces.StatusActionListener
import app.pachli.util.StatusDisplayOptions
@ -36,10 +39,10 @@ class TimelinePagingAdapter(
val inflater = LayoutInflater.from(viewGroup.context)
return when (viewType) {
VIEW_TYPE_STATUS_FILTERED -> {
StatusViewHolder(inflater.inflate(R.layout.item_status_wrapper, viewGroup, false))
FilterableStatusViewHolder(ItemStatusWrapperBinding.inflate(inflater, viewGroup, false))
}
VIEW_TYPE_STATUS -> {
StatusViewHolder(inflater.inflate(R.layout.item_status, viewGroup, false))
StatusViewHolder(ItemStatusBinding.inflate(inflater, viewGroup, false))
}
else -> return object : RecyclerView.ViewHolder(inflater.inflate(R.layout.item_placeholder, viewGroup, false)) {}
}

View File

@ -19,10 +19,13 @@ import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import app.pachli.R
import app.pachli.adapter.FilterableStatusViewHolder
import app.pachli.adapter.StatusBaseViewHolder
import app.pachli.adapter.StatusDetailedViewHolder
import app.pachli.adapter.StatusViewHolder
import app.pachli.databinding.ItemStatusBinding
import app.pachli.databinding.ItemStatusDetailedBinding
import app.pachli.databinding.ItemStatusWrapperBinding
import app.pachli.entity.Filter
import app.pachli.interfaces.StatusActionListener
import app.pachli.util.StatusDisplayOptions
@ -37,13 +40,13 @@ class ThreadAdapter(
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
VIEW_TYPE_STATUS -> {
StatusViewHolder(inflater.inflate(R.layout.item_status, parent, false))
StatusViewHolder(ItemStatusBinding.inflate(inflater, parent, false))
}
VIEW_TYPE_STATUS_FILTERED -> {
StatusViewHolder(inflater.inflate(R.layout.item_status_wrapper, parent, false))
FilterableStatusViewHolder(ItemStatusWrapperBinding.inflate(inflater, parent, false))
}
VIEW_TYPE_STATUS_DETAILED -> {
StatusDetailedViewHolder(inflater.inflate(R.layout.item_status_detailed, parent, false))
StatusDetailedViewHolder(ItemStatusDetailedBinding.inflate(inflater, parent, false))
}
else -> error("Unknown item type: $viewType")
}

View File

@ -16,6 +16,8 @@
package app.pachli.entity
import android.os.Parcelable
import androidx.annotation.DrawableRes
import app.pachli.R
import com.google.gson.JsonDeserializationContext
import com.google.gson.JsonDeserializer
import com.google.gson.JsonElement
@ -53,6 +55,15 @@ data class Attachment(
UNKNOWN,
}
/** @return a drawable resource for an icon to indicate the attachment type */
@DrawableRes
fun iconResource() = when (this.type) {
Type.IMAGE -> R.drawable.ic_photo_24dp
Type.GIFV, Type.VIDEO -> R.drawable.ic_videocam_24dp
Type.AUDIO -> R.drawable.ic_music_box_24dp
Type.UNKNOWN -> R.drawable.ic_attach_file_24dp
}
class MediaTypeDeserializer : JsonDeserializer<Type> {
@Throws(JsonParseException::class)
override fun deserialize(json: JsonElement, classOfT: java.lang.reflect.Type, context: JsonDeserializationContext): Type {

View File

@ -15,8 +15,13 @@
package app.pachli.entity
import android.content.Context
import android.graphics.drawable.Drawable
import android.text.SpannableStringBuilder
import android.text.style.URLSpan
import android.widget.TextView
import androidx.appcompat.content.res.AppCompatResources
import app.pachli.R
import app.pachli.util.parseAsMastodonHtml
import com.google.gson.annotations.SerializedName
import java.util.Date
@ -59,13 +64,6 @@ data class Status(
val actionableStatus: Status
get() = reblog ?: this
/** Helpers for Java */
fun copyWithFavourited(favourited: Boolean): Status = copy(favourited = favourited)
fun copyWithReblogged(reblogged: Boolean): Status = copy(reblogged = reblogged)
fun copyWithBookmarked(bookmarked: Boolean): Status = copy(bookmarked = bookmarked)
fun copyWithPoll(poll: Poll?): Status = copy(poll = poll)
fun copyWithPinned(pinned: Boolean): Status = copy(pinned = pinned)
enum class Visibility(val num: Int) {
UNKNOWN(0),
@ -178,3 +176,43 @@ data class Status(
const val MAX_POLL_OPTIONS = 4
}
}
/**
* @return A description for this visibility, or "" if it's null or [Status.Visibility.UNKNOWN].
*/
fun Status.Visibility?.description(context: Context): CharSequence {
this ?: return ""
val resource: Int = when (this) {
Status.Visibility.PUBLIC -> R.string.description_visibility_public
Status.Visibility.UNLISTED -> R.string.description_visibility_unlisted
Status.Visibility.PRIVATE -> R.string.description_visibility_private
Status.Visibility.DIRECT -> R.string.description_visibility_direct
Status.Visibility.UNKNOWN -> return ""
}
return context.getString(resource)
}
/**
* @return An icon for this visibility scaled and coloured to match the text on [textView].
* Returns null if visibility is [Status.Visibility.UNKNOWN].
*/
fun Status.Visibility?.icon(textView: TextView): Drawable? {
this ?: return null
val resource: Int = when (this) {
Status.Visibility.PUBLIC -> R.drawable.ic_public_24dp
Status.Visibility.UNLISTED -> R.drawable.ic_lock_open_24dp
Status.Visibility.PRIVATE -> R.drawable.ic_lock_outline_24dp
Status.Visibility.DIRECT -> R.drawable.ic_email_24dp
Status.Visibility.UNKNOWN -> return null
}
val visibilityDrawable = AppCompatResources.getDrawable(
textView.context,
resource,
) ?: return null
val size = textView.textSize.toInt()
visibilityDrawable.setBounds(0, 0, size, size)
visibilityDrawable.setTint(textView.currentTextColor)
return visibilityDrawable
}

View File

@ -62,7 +62,7 @@ class PollView @JvmOverloads constructor(
* should be treated as a navigation click. If non-null the user has voted,
* and [choices] contains the option(s) they voted for.
*/
fun onClick(choices: List<Int>?): Unit
fun onClick(choices: List<Int>?)
}
val binding: StatusPollBinding

View File

@ -259,7 +259,7 @@
sparkbutton:secondaryColor="?colorPrimaryContainer" />
<TextView
android:id="@+id/status_insets"
android:id="@+id/status_reblogs_count"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="45dp"

View File

@ -4,10 +4,12 @@
android:layout_width="match_parent"
android:layout_height="wrap_content">
<include layout="@layout/item_status" />
<include
android:id="@+id/status_container"
layout="@layout/item_status" />
<include
android:id="@+id/status_filtered_placeholder"
layout="@layout/item_status_filtered"
android:visibility="gone"
/>
android:visibility="gone" />
</FrameLayout>