StatusNotificationViewHolder.kt 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  1. /*
  2. * Copyright 2023 Tusky Contributors
  3. *
  4. * This file is a part of Tusky.
  5. *
  6. * This program is free software; you can redistribute it and/or modify it under the terms of the
  7. * GNU General Public License as published by the Free Software Foundation; either version 3 of the
  8. * License, or (at your option) any later version.
  9. *
  10. * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
  11. * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
  12. * Public License for more details.
  13. *
  14. * You should have received a copy of the GNU General Public License along with Tusky; if not,
  15. * see <http://www.gnu.org/licenses>.
  16. */
  17. package com.keylesspalace.tusky.components.notifications
  18. import android.content.Context
  19. import android.graphics.PorterDuff
  20. import android.graphics.Typeface
  21. import android.graphics.drawable.Drawable
  22. import android.text.InputFilter
  23. import android.text.SpannableStringBuilder
  24. import android.text.Spanned
  25. import android.text.TextUtils
  26. import android.text.format.DateUtils
  27. import android.text.style.StyleSpan
  28. import android.view.View
  29. import androidx.annotation.ColorRes
  30. import androidx.annotation.DrawableRes
  31. import androidx.core.content.ContextCompat
  32. import androidx.recyclerview.widget.RecyclerView
  33. import at.connyduck.sparkbutton.helpers.Utils
  34. import com.bumptech.glide.Glide
  35. import com.keylesspalace.tusky.R
  36. import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
  37. import com.keylesspalace.tusky.databinding.ItemStatusNotificationBinding
  38. import com.keylesspalace.tusky.entity.Emoji
  39. import com.keylesspalace.tusky.entity.Notification
  40. import com.keylesspalace.tusky.interfaces.LinkListener
  41. import com.keylesspalace.tusky.interfaces.StatusActionListener
  42. import com.keylesspalace.tusky.util.AbsoluteTimeFormatter
  43. import com.keylesspalace.tusky.util.SmartLengthInputFilter
  44. import com.keylesspalace.tusky.util.StatusDisplayOptions
  45. import com.keylesspalace.tusky.util.emojify
  46. import com.keylesspalace.tusky.util.getRelativeTimeSpanString
  47. import com.keylesspalace.tusky.util.loadAvatar
  48. import com.keylesspalace.tusky.util.setClickableText
  49. import com.keylesspalace.tusky.util.unicodeWrap
  50. import com.keylesspalace.tusky.viewdata.NotificationViewData
  51. import com.keylesspalace.tusky.viewdata.StatusViewData
  52. import java.util.Date
  53. /**
  54. * View holder for a status with an activity to be notified about (posted, boosted,
  55. * favourited, or edited, per [NotificationViewKind.from]).
  56. *
  57. * Shows a line with the activity, and who initiated the activity. Clicking this should
  58. * go to the profile page for the initiator.
  59. *
  60. * Displays the original status below that. Clicking this should go to the original
  61. * status in context.
  62. */
  63. internal class StatusNotificationViewHolder(
  64. private val binding: ItemStatusNotificationBinding,
  65. private val statusActionListener: StatusActionListener,
  66. private val notificationActionListener: NotificationActionListener,
  67. private val absoluteTimeFormatter: AbsoluteTimeFormatter
  68. ) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) {
  69. private val avatarRadius48dp = itemView.context.resources.getDimensionPixelSize(
  70. R.dimen.avatar_radius_48dp
  71. )
  72. private val avatarRadius36dp = itemView.context.resources.getDimensionPixelSize(
  73. R.dimen.avatar_radius_36dp
  74. )
  75. private val avatarRadius24dp = itemView.context.resources.getDimensionPixelSize(
  76. R.dimen.avatar_radius_24dp
  77. )
  78. override fun bind(
  79. viewData: NotificationViewData,
  80. payloads: List<*>?,
  81. statusDisplayOptions: StatusDisplayOptions
  82. ) {
  83. val statusViewData = viewData.statusViewData
  84. if (payloads.isNullOrEmpty()) {
  85. // Hide null statuses. Shouldn't happen according to the spec, but some servers
  86. // have been seen to do this (https://github.com/tuskyapp/Tusky/issues/2252)
  87. if (statusViewData == null) {
  88. showNotificationContent(false)
  89. } else {
  90. showNotificationContent(true)
  91. val (_, _, account, _, _, _, _, createdAt) = statusViewData.actionable
  92. setDisplayName(account.name, account.emojis, statusDisplayOptions.animateEmojis)
  93. setUsername(account.username)
  94. setCreatedAt(createdAt, statusDisplayOptions.useAbsoluteTime)
  95. if (viewData.type == Notification.Type.STATUS ||
  96. viewData.type == Notification.Type.UPDATE
  97. ) {
  98. setAvatar(
  99. account.avatar,
  100. account.bot,
  101. statusDisplayOptions.animateAvatars,
  102. statusDisplayOptions.showBotOverlay
  103. )
  104. } else {
  105. setAvatars(
  106. account.avatar,
  107. viewData.account.avatar,
  108. statusDisplayOptions.animateAvatars
  109. )
  110. }
  111. binding.notificationContainer.setOnClickListener {
  112. notificationActionListener.onViewThreadForStatus(statusViewData.status)
  113. }
  114. binding.notificationContent.setOnClickListener {
  115. notificationActionListener.onViewThreadForStatus(statusViewData.status)
  116. }
  117. binding.notificationTopText.setOnClickListener {
  118. notificationActionListener.onViewAccount(viewData.account.id)
  119. }
  120. }
  121. setMessage(viewData, statusActionListener, statusDisplayOptions.animateEmojis)
  122. } else {
  123. for (item in payloads) {
  124. if (StatusBaseViewHolder.Key.KEY_CREATED == item && statusViewData != null) {
  125. setCreatedAt(
  126. statusViewData.status.actionableStatus.createdAt,
  127. statusDisplayOptions.useAbsoluteTime
  128. )
  129. }
  130. }
  131. }
  132. }
  133. private fun showNotificationContent(show: Boolean) {
  134. binding.statusNameBar.visibility = if (show) View.VISIBLE else View.GONE
  135. binding.notificationContentWarningDescription.visibility =
  136. if (show) View.VISIBLE else View.GONE
  137. binding.notificationContentWarningButton.visibility =
  138. if (show) View.VISIBLE else View.GONE
  139. binding.notificationContent.visibility = if (show) View.VISIBLE else View.GONE
  140. binding.notificationStatusAvatar.visibility = if (show) View.VISIBLE else View.GONE
  141. binding.notificationNotificationAvatar.visibility = if (show) View.VISIBLE else View.GONE
  142. }
  143. private fun setDisplayName(name: String, emojis: List<Emoji>?, animateEmojis: Boolean) {
  144. val emojifiedName = name.emojify(emojis, binding.statusDisplayName, animateEmojis)
  145. binding.statusDisplayName.text = emojifiedName
  146. }
  147. private fun setUsername(name: String) {
  148. val context = binding.statusUsername.context
  149. val format = context.getString(R.string.post_username_format)
  150. val usernameText = String.format(format, name)
  151. binding.statusUsername.text = usernameText
  152. }
  153. private fun setCreatedAt(createdAt: Date?, useAbsoluteTime: Boolean) {
  154. if (useAbsoluteTime) {
  155. binding.statusMetaInfo.text = absoluteTimeFormatter.format(createdAt, true)
  156. } else {
  157. // This is the visible timestampInfo.
  158. val readout: String
  159. /* This one is for screen-readers. Frequently, they would mispronounce timestamps like "17m"
  160. * as 17 meters instead of minutes. */
  161. val readoutAloud: CharSequence
  162. if (createdAt != null) {
  163. val then = createdAt.time
  164. val now = Date().time
  165. readout = getRelativeTimeSpanString(binding.statusMetaInfo.context, then, now)
  166. readoutAloud = DateUtils.getRelativeTimeSpanString(
  167. then,
  168. now,
  169. DateUtils.SECOND_IN_MILLIS,
  170. DateUtils.FORMAT_ABBREV_RELATIVE
  171. )
  172. } else {
  173. // unknown minutes~
  174. readout = "?m"
  175. readoutAloud = "? minutes"
  176. }
  177. binding.statusMetaInfo.text = readout
  178. binding.statusMetaInfo.contentDescription = readoutAloud
  179. }
  180. }
  181. private fun getIconWithColor(
  182. context: Context,
  183. @DrawableRes drawable: Int,
  184. @ColorRes color: Int
  185. ): Drawable? {
  186. val icon = ContextCompat.getDrawable(context, drawable)
  187. icon?.setColorFilter(context.getColor(color), PorterDuff.Mode.SRC_ATOP)
  188. return icon
  189. }
  190. private fun setAvatar(statusAvatarUrl: String?, isBot: Boolean, animateAvatars: Boolean, showBotOverlay: Boolean) {
  191. binding.notificationStatusAvatar.setPaddingRelative(0, 0, 0, 0)
  192. loadAvatar(
  193. statusAvatarUrl,
  194. binding.notificationStatusAvatar,
  195. avatarRadius48dp,
  196. animateAvatars
  197. )
  198. if (showBotOverlay && isBot) {
  199. binding.notificationNotificationAvatar.visibility = View.VISIBLE
  200. Glide.with(binding.notificationNotificationAvatar)
  201. .load(R.drawable.bot_badge)
  202. .into(binding.notificationNotificationAvatar)
  203. } else {
  204. binding.notificationNotificationAvatar.visibility = View.GONE
  205. }
  206. }
  207. private fun setAvatars(statusAvatarUrl: String?, notificationAvatarUrl: String?, animateAvatars: Boolean) {
  208. val padding = Utils.dpToPx(binding.notificationStatusAvatar.context, 12)
  209. binding.notificationStatusAvatar.setPaddingRelative(0, 0, padding, padding)
  210. loadAvatar(
  211. statusAvatarUrl,
  212. binding.notificationStatusAvatar,
  213. avatarRadius36dp,
  214. animateAvatars
  215. )
  216. binding.notificationNotificationAvatar.visibility = View.VISIBLE
  217. loadAvatar(
  218. notificationAvatarUrl,
  219. binding.notificationNotificationAvatar,
  220. avatarRadius24dp,
  221. animateAvatars
  222. )
  223. }
  224. fun setMessage(
  225. notificationViewData: NotificationViewData,
  226. listener: LinkListener,
  227. animateEmojis: Boolean
  228. ) {
  229. val statusViewData = notificationViewData.statusViewData
  230. val displayName = notificationViewData.account.name.unicodeWrap()
  231. val type = notificationViewData.type
  232. val context = binding.notificationTopText.context
  233. val format: String
  234. val icon: Drawable?
  235. when (type) {
  236. Notification.Type.FAVOURITE -> {
  237. icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange)
  238. format = context.getString(R.string.notification_favourite_format)
  239. }
  240. Notification.Type.REBLOG -> {
  241. icon = getIconWithColor(context, R.drawable.ic_repeat_24dp, R.color.tusky_blue)
  242. format = context.getString(R.string.notification_reblog_format)
  243. }
  244. Notification.Type.STATUS -> {
  245. icon = getIconWithColor(context, R.drawable.ic_home_24dp, R.color.tusky_blue)
  246. format = context.getString(R.string.notification_subscription_format)
  247. }
  248. Notification.Type.UPDATE -> {
  249. icon = getIconWithColor(context, R.drawable.ic_edit_24dp, R.color.tusky_blue)
  250. format = context.getString(R.string.notification_update_format)
  251. }
  252. else -> {
  253. icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange)
  254. format = context.getString(R.string.notification_favourite_format)
  255. }
  256. }
  257. binding.notificationTopText.setCompoundDrawablesWithIntrinsicBounds(
  258. icon,
  259. null,
  260. null,
  261. null
  262. )
  263. val wholeMessage = String.format(format, displayName)
  264. val str = SpannableStringBuilder(wholeMessage)
  265. val displayNameIndex = format.indexOf("%s")
  266. str.setSpan(
  267. StyleSpan(Typeface.BOLD),
  268. displayNameIndex,
  269. displayNameIndex + displayName.length,
  270. Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
  271. )
  272. val emojifiedText = str.emojify(
  273. notificationViewData.account.emojis,
  274. binding.notificationTopText,
  275. animateEmojis
  276. )
  277. binding.notificationTopText.text = emojifiedText
  278. if (statusViewData != null) {
  279. val hasSpoiler = !TextUtils.isEmpty(statusViewData.status.spoilerText)
  280. binding.notificationContentWarningDescription.visibility =
  281. if (hasSpoiler) View.VISIBLE else View.GONE
  282. binding.notificationContentWarningButton.visibility =
  283. if (hasSpoiler) View.VISIBLE else View.GONE
  284. if (statusViewData.isExpanded) {
  285. binding.notificationContentWarningButton.setText(
  286. R.string.post_content_warning_show_less
  287. )
  288. } else {
  289. binding.notificationContentWarningButton.setText(
  290. R.string.post_content_warning_show_more
  291. )
  292. }
  293. binding.notificationContentWarningButton.setOnClickListener {
  294. if (bindingAdapterPosition != RecyclerView.NO_POSITION) {
  295. notificationActionListener.onExpandedChange(
  296. !statusViewData.isExpanded,
  297. bindingAdapterPosition
  298. )
  299. }
  300. binding.notificationContent.visibility =
  301. if (statusViewData.isExpanded) View.GONE else View.VISIBLE
  302. }
  303. setupContentAndSpoiler(listener, statusViewData, animateEmojis)
  304. }
  305. }
  306. private fun setupContentAndSpoiler(
  307. listener: LinkListener,
  308. statusViewData: StatusViewData.Concrete,
  309. animateEmojis: Boolean
  310. ) {
  311. val shouldShowContentIfSpoiler = statusViewData.isExpanded
  312. val hasSpoiler = !TextUtils.isEmpty(statusViewData.status.spoilerText)
  313. if (!shouldShowContentIfSpoiler && hasSpoiler) {
  314. binding.notificationContent.visibility = View.GONE
  315. } else {
  316. binding.notificationContent.visibility = View.VISIBLE
  317. }
  318. val content = statusViewData.content
  319. val emojis = statusViewData.actionable.emojis
  320. if (statusViewData.isCollapsible && (statusViewData.isExpanded || !hasSpoiler)) {
  321. binding.buttonToggleNotificationContent.setOnClickListener {
  322. val position = bindingAdapterPosition
  323. if (position != RecyclerView.NO_POSITION) {
  324. notificationActionListener.onNotificationContentCollapsedChange(
  325. !statusViewData.isCollapsed,
  326. position
  327. )
  328. }
  329. }
  330. binding.buttonToggleNotificationContent.visibility = View.VISIBLE
  331. if (statusViewData.isCollapsed) {
  332. binding.buttonToggleNotificationContent.setText(
  333. R.string.post_content_warning_show_more
  334. )
  335. binding.notificationContent.filters = COLLAPSE_INPUT_FILTER
  336. } else {
  337. binding.buttonToggleNotificationContent.setText(
  338. R.string.post_content_warning_show_less
  339. )
  340. binding.notificationContent.filters = NO_INPUT_FILTER
  341. }
  342. } else {
  343. binding.buttonToggleNotificationContent.visibility = View.GONE
  344. binding.notificationContent.filters = NO_INPUT_FILTER
  345. }
  346. val emojifiedText =
  347. content.emojify(
  348. emojis,
  349. binding.notificationContent,
  350. animateEmojis
  351. )
  352. setClickableText(
  353. binding.notificationContent,
  354. emojifiedText,
  355. statusViewData.actionable.mentions,
  356. statusViewData.actionable.tags,
  357. listener
  358. )
  359. val emojifiedContentWarning: CharSequence = statusViewData.spoilerText.emojify(
  360. statusViewData.actionable.emojis,
  361. binding.notificationContentWarningDescription,
  362. animateEmojis
  363. )
  364. binding.notificationContentWarningDescription.text = emojifiedContentWarning
  365. }
  366. companion object {
  367. private val COLLAPSE_INPUT_FILTER = arrayOf<InputFilter>(SmartLengthInputFilter)
  368. private val NO_INPUT_FILTER = arrayOfNulls<InputFilter>(0)
  369. }
  370. }