EmojiReactionsPopup.kt 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. /* _____ _
  2. * |_ _| |_ _ _ ___ ___ _ __ __ _
  3. * | | | ' \| '_/ -_) -_) ' \/ _` |_
  4. * |_| |_||_|_| \___\___|_|_|_\__,_(_)
  5. *
  6. * Threema for Android
  7. * Copyright (c) 2024 Threema GmbH
  8. *
  9. * This program is free software: you can redistribute it and/or modify
  10. * it under the terms of the GNU Affero General Public License, version 3,
  11. * as published by the Free Software Foundation.
  12. *
  13. * This program is distributed in the hope that it will be useful,
  14. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  15. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  16. * GNU Affero General Public License for more details.
  17. *
  18. * You should have received a copy of the GNU Affero General Public License
  19. * along with this program. If not, see <https://www.gnu.org/licenses/>.
  20. */
  21. package ch.threema.app.emojireactions
  22. import android.content.Context
  23. import android.graphics.drawable.BitmapDrawable
  24. import android.view.Gravity
  25. import android.view.LayoutInflater
  26. import android.view.View
  27. import android.view.ViewTreeObserver.OnGlobalLayoutListener
  28. import android.widget.FrameLayout
  29. import android.widget.ImageView
  30. import android.widget.PopupWindow
  31. import androidx.annotation.StringRes
  32. import androidx.core.content.res.ResourcesCompat
  33. import androidx.core.view.isVisible
  34. import androidx.fragment.app.FragmentManager
  35. import ch.threema.app.R
  36. import ch.threema.app.ThreemaApplication
  37. import ch.threema.app.dialogs.SimpleStringAlertDialog
  38. import ch.threema.app.emojis.EmojiItemView
  39. import ch.threema.app.emojis.EmojiUtil
  40. import ch.threema.app.services.ContactService
  41. import ch.threema.app.services.UserService
  42. import ch.threema.app.utils.AnimationUtil
  43. import ch.threema.app.utils.ConfigUtils
  44. import ch.threema.app.utils.NameUtil
  45. import ch.threema.app.utils.ViewUtil
  46. import ch.threema.data.models.EmojiReactionsModel
  47. import ch.threema.data.repositories.EmojiReactionsRepository
  48. import ch.threema.storage.models.AbstractMessageModel
  49. import ch.threema.storage.models.GroupMessageModel
  50. private const val FAKE_DISABLE_ALPHA = 0.2f
  51. class EmojiReactionsPopup(
  52. val context: Context,
  53. private val parentView: View,
  54. val fragmentManager: FragmentManager,
  55. private val isSendingReactionsAllowed: Boolean,
  56. private val shouldHideUnsupportedReactions: Boolean,
  57. ) :
  58. PopupWindow(context), View.OnClickListener {
  59. private val addReactionButton: ImageView
  60. private var emojiReactionsPopupListener: EmojiReactionsPopupListener? = null
  61. private val popupHeight =
  62. 2 * context.resources.getDimensionPixelSize(R.dimen.reaction_popup_content_margin) +
  63. context.resources.getDimensionPixelSize(R.dimen.emoji_popup_cardview_margin_bottom) +
  64. context.resources.getDimensionPixelSize(R.dimen.reaction_popup_emoji_size)
  65. private val popupHorizontalOffset = context.resources.getDimensionPixelSize(R.dimen.reaction_popup_content_margin_horizontal)
  66. private var messageModel: AbstractMessageModel? = null
  67. private val emojiReactionsRepository: EmojiReactionsRepository = ThreemaApplication.requireServiceManager().modelRepositories.emojiReaction
  68. private val userService: UserService = ThreemaApplication.requireServiceManager().userService
  69. private val emojiService = ThreemaApplication.requireServiceManager().emojiService
  70. private val selectedBackgroundColor = ResourcesCompat.getDrawable(context.resources, R.drawable.shape_emoji_popup_selected_background, null)
  71. private val backgroundColor = ResourcesCompat.getColor(context.resources, android.R.color.transparent, null)
  72. private val topReactions = arrayOf(
  73. ReactionEntry(R.id.top_0, EmojiUtil.THUMBS_UP_SEQUENCE),
  74. ReactionEntry(R.id.top_1, EmojiUtil.THUMBS_DOWN_SEQUENCE),
  75. ReactionEntry(R.id.top_2, EmojiUtil.HEART_SEQUENCE),
  76. ReactionEntry(R.id.top_3, EmojiUtil.TEARS_OF_JOY_SEQUENCE),
  77. ReactionEntry(R.id.top_4, EmojiUtil.CRYING_SEQUENCE),
  78. ReactionEntry(R.id.top_5, EmojiUtil.FOLDED_HANDS_SEQUENCE),
  79. )
  80. private val contactService: ContactService by lazy { ThreemaApplication.requireServiceManager().contactService }
  81. init {
  82. val layoutInflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
  83. contentView = layoutInflater.inflate(R.layout.popup_emojireactions, null, true) as FrameLayout
  84. setupTopReactions()
  85. addReactionButton = contentView.findViewById(R.id.add_reaction)
  86. addReactionButton.tag = topReactions.size
  87. addReactionButton.setOnClickListener(this)
  88. if (!isSendingReactionsAllowed) {
  89. if (ConfigUtils.canSendEmojiReactions() && !shouldHideUnsupportedReactions) {
  90. // V2 clients: display implausible buttons as disabled but still clickable
  91. addReactionButton.alpha = FAKE_DISABLE_ALPHA
  92. } else {
  93. // V1 clients, or gateway chat: do not display implausible buttons
  94. addReactionButton.isVisible = false
  95. }
  96. }
  97. inputMethodMode = INPUT_METHOD_NOT_NEEDED
  98. width = FrameLayout.LayoutParams.WRAP_CONTENT
  99. height = FrameLayout.LayoutParams.WRAP_CONTENT
  100. setBackgroundDrawable(BitmapDrawable())
  101. animationStyle = 0
  102. isOutsideTouchable = true
  103. isTouchable = true
  104. isFocusable = true
  105. ViewUtil.setTouchModal(this, false)
  106. }
  107. private fun setupTopReactions() {
  108. topReactions.forEachIndexed { index, topReaction ->
  109. val emojiItemView: EmojiItemView = contentView.findViewById(topReaction.resourceId)
  110. applyEmojiDiversity(topReaction)
  111. emojiItemView.tag = index
  112. emojiItemView.setOnClickListener(this)
  113. emojiItemView.setEmoji(topReaction.emojiSequence, false, 0)
  114. if (isDisabledOrHiddenButton(index)) {
  115. if (ConfigUtils.canSendEmojiReactions() && !shouldHideUnsupportedReactions) {
  116. // V2 clients: display implausible buttons as disabled but still clickable
  117. emojiItemView.alpha = FAKE_DISABLE_ALPHA
  118. } else {
  119. // V1 clients: do not display implausible buttons
  120. emojiItemView.isVisible = false
  121. }
  122. }
  123. topReaction.emojiItemView = emojiItemView
  124. }
  125. }
  126. private fun applyEmojiDiversity(topReaction: ReactionEntry) {
  127. if (isSendingReactionsAllowed) {
  128. val value = emojiService.getPreferredDiversity(topReaction.emojiSequence)
  129. if (value != topReaction.emojiSequence) {
  130. topReaction.emojiSequence = value
  131. }
  132. }
  133. }
  134. fun show(
  135. originView: View,
  136. messageModel: AbstractMessageModel?
  137. ) {
  138. if (messageModel == null) {
  139. return
  140. }
  141. this.messageModel = messageModel
  142. isDismissing = false
  143. val horizontalOffset = if (messageModel.isOutbox) 0 else this.popupHorizontalOffset
  144. val originLocation = intArrayOf(0, 0)
  145. originView.getLocationInWindow(originLocation)
  146. showAtLocation(
  147. parentView,
  148. Gravity.LEFT or Gravity.TOP,
  149. originLocation[0] + horizontalOffset,
  150. originLocation[1] - this.popupHeight
  151. )
  152. val emojiReactionsModel: EmojiReactionsModel? = emojiReactionsRepository.getReactionsByMessage(messageModel)
  153. contentView.viewTreeObserver.addOnGlobalLayoutListener(object : OnGlobalLayoutListener {
  154. override fun onGlobalLayout() {
  155. contentView.viewTreeObserver.removeOnGlobalLayoutListener(this)
  156. AnimationUtil.popupAnimateIn(contentView)
  157. var animationDelay = 10
  158. val animationDelayStep = 60
  159. for (topReaction in topReactions) {
  160. if (topReaction.emojiItemView != null) {
  161. AnimationUtil.bubbleAnimate(
  162. topReaction.emojiItemView,
  163. animationDelayStep.let { animationDelay += it; animationDelay })
  164. if (hasUserReacted(topReaction, emojiReactionsModel)) {
  165. topReaction.emojiItemView?.background = selectedBackgroundColor
  166. } else {
  167. topReaction.emojiItemView?.setBackgroundColor(backgroundColor)
  168. }
  169. }
  170. }
  171. AnimationUtil.bubbleAnimate(addReactionButton, animationDelay)
  172. }
  173. })
  174. }
  175. private fun hasUserReacted(topReaction: ReactionEntry, emojiReactionsModel: EmojiReactionsModel?): Boolean {
  176. val reactionList = emojiReactionsModel?.data?.value
  177. return reactionList?.any { reaction ->
  178. reaction.emojiSequence == topReaction.emojiSequence && reaction.senderIdentity == userService.identity
  179. } ?: false
  180. }
  181. /**
  182. * Check if the button with the supplied index is unsupported and
  183. * should therefore be fake-disabled or hidden
  184. */
  185. private fun isDisabledOrHiddenButton(index: Int): Boolean =
  186. !isSendingReactionsAllowed && index >= 2
  187. override fun dismiss() {
  188. if (isDismissing) {
  189. return
  190. }
  191. isDismissing = true
  192. AnimationUtil.popupAnimateOut(contentView) {
  193. super.dismiss()
  194. }
  195. }
  196. fun setListener(listener: EmojiReactionsPopupListener?) {
  197. this.emojiReactionsPopupListener = listener
  198. }
  199. override fun onClick(v: View) {
  200. emojiReactionsPopupListener?.let { listener ->
  201. this.messageModel?.let { message ->
  202. if (isDisabledOrHiddenButton(v.tag as Int)) {
  203. onDisabledButtonClicked()
  204. return
  205. } else {
  206. if (v.id == addReactionButton.id) {
  207. listener.onAddReactionClicked(message)
  208. } else {
  209. if (v is EmojiItemView) {
  210. if (isSendingReactionsAllowed || v.background != selectedBackgroundColor) {
  211. listener.onTopReactionClicked(message, v.emoji)
  212. } else {
  213. // A reaction that cannot be sent was clicked (e.g. Thumbs up was clicked,
  214. // while a thumbs up is already sent, but only ack/dec are supported)
  215. onImpossibleReactionClicked()
  216. return
  217. }
  218. }
  219. }
  220. }
  221. }
  222. }
  223. dismiss()
  224. }
  225. private fun onDisabledButtonClicked() {
  226. val body = if (messageModel is GroupMessageModel) {
  227. context.getString(R.string.emoji_reactions_unavailable_group_body)
  228. } else {
  229. messageModel.getDisplayNameOrNickname()
  230. ?.let { name -> context.getString(R.string.emoji_reactions_unavailable_body, name) }
  231. }
  232. createAlertDialogIfBodySet(
  233. R.string.emoji_reactions_unavailable_title,
  234. body
  235. )?.show(fragmentManager, "dis")
  236. }
  237. private fun onImpossibleReactionClicked() {
  238. val body = if (ConfigUtils.canSendEmojiReactions()) {
  239. if (messageModel is GroupMessageModel) {
  240. context.getString(R.string.emoji_reactions_cannot_remove_group_body)
  241. } else {
  242. messageModel.getDisplayNameOrNickname()
  243. ?.let { name -> context.getString(R.string.emoji_reactions_cannot_remove_body, name) }
  244. }
  245. } else {
  246. context.getString(R.string.emoji_reactions_cannot_remove_v1_body)
  247. }
  248. createAlertDialogIfBodySet(
  249. R.string.emoji_reactions_cannot_remove_title,
  250. body
  251. )?.show(fragmentManager, "imp")
  252. }
  253. private fun createAlertDialogIfBodySet(@StringRes titleResId: Int, body: String?): SimpleStringAlertDialog? {
  254. return body?.let {
  255. SimpleStringAlertDialog.newInstance(titleResId, it)
  256. }
  257. }
  258. private fun AbstractMessageModel?.getDisplayNameOrNickname(): String? {
  259. return this?.let { NameUtil.getDisplayNameOrNickname(context, it, contactService) }
  260. }
  261. interface EmojiReactionsPopupListener {
  262. fun onTopReactionClicked(messageModel: AbstractMessageModel, emojiSequence: String)
  263. fun onAddReactionClicked(messageModel: AbstractMessageModel)
  264. }
  265. companion object {
  266. private var isDismissing = false
  267. }
  268. private class ReactionEntry(val resourceId: Int, var emojiSequence: String) {
  269. var emojiItemView: EmojiItemView? = null
  270. }
  271. }