Newer
Older
* Copyright (C) 2004-2023 Savoir-faire Linux Inc.
* 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.
*
* This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
*/
package cx.ring.adapters
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ValueAnimator
import android.annotation.SuppressLint
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.graphics.SurfaceTexture
import android.graphics.drawable.Drawable
import android.graphics.drawable.GradientDrawable
import android.text.format.DateUtils
import android.text.format.Formatter
import android.util.TypedValue
import android.view.*
import android.view.ContextMenu.ContextMenuInfo
import android.view.TextureView.SurfaceTextureListener
import android.view.ViewGroup.MarginLayoutParams
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.PopupWindow
import android.widget.TextView
import androidx.cardview.widget.CardView
import androidx.core.app.ActivityOptionsCompat
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.view.children
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.recyclerview.widget.RecyclerView
import androidx.vectordrawable.graphics.drawable.Animatable2Compat
import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions.withCrossFade
import com.google.android.material.color.MaterialColors
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import cx.ring.R
import cx.ring.client.MediaViewerActivity
import cx.ring.client.MessageEditActivity
import cx.ring.databinding.MenuConversationBinding
import cx.ring.fragments.ConversationFragment
import cx.ring.linkpreview.LinkPreview
import cx.ring.linkpreview.PreviewData
import cx.ring.utils.ActionHelper.Padding
import cx.ring.utils.ActionHelper.setPadding
import cx.ring.utils.ContentUriHandler.getUriForFile
import io.noties.markwon.AbstractMarkwonPlugin
import io.noties.markwon.MarkwonVisitor
import io.noties.markwon.linkify.LinkifyPlugin
import io.reactivex.rxjava3.core.Observable
import net.jami.conversation.ConversationPresenter
import net.jami.model.*
import net.jami.model.Account.ComposingStatus
import net.jami.model.Interaction.InteractionStatus
import net.jami.utils.Log
import org.commonmark.node.SoftLineBreak
import java.io.File
import java.util.*
import java.util.concurrent.TimeUnit
import kotlin.math.max
class ConversationAdapter(
private val conversationFragment: ConversationFragment,
private val presenter: ConversationPresenter,
private val isSearch: Boolean = false
) : RecyclerView.Adapter<ConversationViewHolder>() {
private val mInteractions = ArrayList<Interaction>()
private val mPictureMaxSize =
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 200f, res.displayMetrics).toInt()
private var mCurrentLongItem: RecyclerViewContextMenuInfo? = null
@ColorInt
private var convColor = 0
@ColorInt
private var convColorTint = 0
private val formatter = Formatter(StringBuilder(64), Locale.getDefault())
private val callPadding = Padding(
res.getDimensionPixelSize(R.dimen.text_message_padding),
res.getDimensionPixelSize(R.dimen.padding_call_vertical),
res.getDimensionPixelSize(R.dimen.text_message_padding),
res.getDimensionPixelSize(R.dimen.padding_call_vertical)
)
private var lastDeliveredPosition = -1
private val timestampUpdateTimer: Observable<Long> =
Observable.interval(10, TimeUnit.SECONDS, DeviceUtils.uiScheduler)
.startWithItem(0L)
private var lastMsgPos = -1
private var isComposing = false
private val markwon: Markwon =
Markwon.builder(conversationFragment.requireContext())
.usePlugin(LinkifyPlugin.create())
// Plugin to add a new line when a soft break is used.
.usePlugin(object : AbstractMarkwonPlugin() {
override fun configureVisitor(builder: MarkwonVisitor.Builder) {
builder.on(SoftLineBreak::class.java) { visitor, _ -> visitor.forceNewLine() }
}
})
.build()
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
/**
* Refreshes the data and notifies the changes
*
* @param list an arraylist of interactions
*/
@SuppressLint("NotifyDataSetChanged")
fun updateDataset(list: List<Interaction>) {
Log.d(TAG, "updateDataset: list size=" + list.size)
when {
mInteractions.isEmpty() -> {
mInteractions.addAll(list)
notifyDataSetChanged()
}
list.size > mInteractions.size -> {
val oldSize = mInteractions.size
mInteractions.addAll(list.subList(oldSize, list.size))
notifyItemRangeInserted(oldSize, list.size)
}
else -> {
mInteractions.clear()
mInteractions.addAll(list)
notifyDataSetChanged()
}
}
}
fun add(e: Interaction): Boolean {
Adrien Béraud
committed
if (e.isSwarm) {
if (mInteractions.isEmpty() || mInteractions[mInteractions.size - 1].messageId == e.parentId) {
val update = mInteractions.isNotEmpty()
mInteractions.add(e)
val previousLast = mInteractions.size - 1
notifyItemInserted(previousLast)
if (update) {
// Find previous last not invalid.
getPreviousInteractionFromPosition(previousLast)?.let { interactionNotInvalid ->
notifyItemChanged(mInteractions.lastIndexOf(interactionNotInvalid))
}
}
return true
}
var i = 0
val n = mInteractions.size
while (i < n) {
if (e.messageId == mInteractions[i].parentId) {
Log.w(TAG, "Adding message at $i previous count $n")
mInteractions.add(i, e)
notifyItemInserted(i)
return i == n - 1
} else if (e.parentId == mInteractions[i].messageId) {
mInteractions.add(i + 1, e)
notifyItemInserted(i + 1)
return true
}
i++
}
} else {
val update = mInteractions.isNotEmpty()
mInteractions.add(e)
notifyItemInserted(mInteractions.size - 1)
if (update) notifyItemChanged(mInteractions.size - 2)
}
return true
}
fun update(editedInteraction: Interaction) {
if (!editedInteraction.isIncoming && editedInteraction.status == InteractionStatus.SUCCESS)
notifyItemChanged(lastDeliveredPosition)
mInteractions.indexOfLast { it.messageId == editedInteraction.messageId }.let {
if (it == -1) return
mInteractions[it] = editedInteraction
notifyItemChanged(it)
}
}
fun remove(e: Interaction) {
Adrien Béraud
committed
if (e.isSwarm) {
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
for (i in mInteractions.indices.reversed()) {
if (e.messageId == mInteractions[i].messageId) {
mInteractions.removeAt(i)
notifyItemRemoved(i)
if (i > 0) {
notifyItemChanged(i - 1)
}
if (i != mInteractions.size) {
notifyItemChanged(i)
}
break
}
}
} else {
for (i in mInteractions.indices.reversed()) {
if (e.id == mInteractions[i].id) {
mInteractions.removeAt(i)
notifyItemRemoved(i)
if (i > 0) {
notifyItemChanged(i - 1)
}
if (i != mInteractions.size) {
notifyItemChanged(i)
}
break
}
}
}
}
fun addSearchResults(interactions: List<Interaction>) {
val oldSize = mInteractions.size
mInteractions.addAll(interactions)
notifyItemRangeInserted(oldSize, interactions.size)
}
fun clearSearchResults() {
mInteractions.clear()
notifyDataSetChanged()
}
/**
* Updates the contact photo to use for this conversation
*/
fun setPhoto() {
notifyDataSetChanged()
override fun getItemCount(): Int = mInteractions.size + if (isComposing) 1 else 0
override fun getItemId(position: Int): Long =
if (isComposing && position == mInteractions.size) Long.MAX_VALUE else mInteractions[position].id.toLong()
fun getMessagePosition(messageId: String): Int {
return mInteractions.indexOfFirst { it.messageId == messageId }
}
override fun getItemViewType(position: Int): Int {
// This function will be called for each item in the list, to know which layout to use.
// Composing indicator
if (isComposing && position == mInteractions.size)
return MessageType.COMPOSING_INDICATION.ordinal
val interaction = mInteractions[position] // Get the interaction
return when (interaction.type) {
Interaction.InteractionType.CONTACT -> MessageType.CONTACT_EVENT.ordinal
Interaction.InteractionType.CALL ->
if ((interaction as Call).isGroupCall) {
MessageType.ONGOING_GROUP_CALL.ordinal
} else if (interaction.isIncoming) {
MessageType.INCOMING_CALL_INFORMATION.ordinal
} else MessageType.OUTGOING_CALL_INFORMATION.ordinal
Interaction.InteractionType.TEXT ->
if (interaction.isIncoming) {
MessageType.INCOMING_TEXT_MESSAGE.ordinal
} else {
MessageType.OUTGOING_TEXT_MESSAGE.ordinal
}
Interaction.InteractionType.DATA_TRANSFER -> {
val file = interaction as DataTransfer
val out = if (interaction.isIncoming) 0 else 4
if (file.isComplete) {
when {
file.isPicture -> return MessageType.INCOMING_IMAGE.ordinal + out
file.isAudio -> return MessageType.INCOMING_AUDIO.ordinal + out
file.isVideo -> return MessageType.INCOMING_VIDEO.ordinal + out
}
}
out
}
Interaction.InteractionType.INVALID -> MessageType.INVALID.ordinal
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ConversationViewHolder {
val type = MessageType.values()[viewType]
val v = if (type == MessageType.INVALID) FrameLayout(parent.context)
else (LayoutInflater.from(parent.context).inflate(type.layout, parent, false) as ViewGroup)
return ConversationViewHolder(v, type)
}
private fun configureDisplayIndicator(
conversationViewHolder: ConversationViewHolder,
interaction: Interaction
) {
val conversation = interaction.conversation
if (conversation == null || conversation !is Conversation) {
conversationViewHolder.mStatusIcon?.isVisible = false
return
}
conversationViewHolder.compositeDisposable.add(presenter.conversationFacade
.getLoadedContact(
interaction.account!!,
conversation,
interaction.displayedContacts
)
.observeOn(DeviceUtils.uiScheduler)
.subscribe { contacts ->
conversationViewHolder.mStatusIcon?.isVisible = contacts.isNotEmpty()
conversationViewHolder.mStatusIcon?.update(
contacts,
interaction.status,
conversationViewHolder.mLayoutStatusIconId?.id ?: View.NO_ID
/**
* Configure a reaction chip and logic about it.
*
* @param conversationViewHolder the view layout.
* @param interaction which have reactions
*/
private fun configureReactions(
conversationViewHolder: ConversationViewHolder,
interaction: Interaction
) {
// If reaction chip is clicked we wants to display the reaction visualiser.
conversationViewHolder.reactionChip?.setOnClickListener {
val adapter = ReactionVisualizerAdapter(
presenter::removeReaction
)
.setAdapter(adapter) { _, _ -> }
.show()
val observable = interaction.reactionObservable
.map { reactions -> reactions.groupBy { reaction -> reaction.contact!! } }
.switchMap { contactReactions ->
if (contactReactions.isEmpty())
return@switchMap Observable.just(emptyList())
// Iterate on entries.
val entry = contactReactions.entries.map { contactReactionsEntry ->
// Launch observeContact and transform the result
presenter.contactService.observeContact(
interaction.account!!, contactReactionsEntry.key, false
).map { contact -> Pair(contact, contactReactionsEntry.value) }
}
// Subscribe on result.
Observable.combineLatest(entry) { resultDictionary ->
it as Pair<ContactViewModel, List<Interaction>>
}
}
}
.observeOn(DeviceUtils.uiScheduler)
.subscribe { reactions ->
// Close dialog if no reactions anymore.
if (reactions.isEmpty()) {
dialog.dismiss()
return@subscribe
}
// Put the account at head of the list.
val mutableList = reactions.toMutableList()
val indexOfUserItem = mutableList.indexOfFirst { it.first.contact.isUser }
if (indexOfUserItem != -1) {
}
adapter.setValues(mutableList)
}
conversationViewHolder.compositeDisposable.add(observable)
dialog.setOnDismissListener {
conversationViewHolder.compositeDisposable.remove(observable)
}
// Manage the display of the chip (ui element showing the emojis)
conversationViewHolder.compositeDisposable.add(
interaction.reactionObservable
.observeOn(DeviceUtils.uiScheduler)
.subscribe { reactions ->
conversationViewHolder.reactionChip?.let { chip ->
// No reaction, hide the chip.
if (reactions.isEmpty())
chip.isVisible = false
else {
chip.text = reactions.filterIsInstance<TextMessage>()
.groupingBy { it.body!! }
.eachCount()
.map { it }
.sortedByDescending { it.value }
.joinToString("") {
if (it.value > 1) it.key + it.value else it.key
}
(chip.background as GradientDrawable).apply {
mutate()
setStroke(
context.resources
.getDimensionPixelSize(R.dimen.message_reaction_stroke),
if (!interaction.isIncoming) convColor
else context.getColor(R.color.border_color)
)
}
chip.isVisible = true
chip.isClickable = true
chip.isFocusable = true

Pierre Nicolas
committed
/**
* Configure a reply Interaction.
*
* @param conversationViewHolder the view layout.
* @param interaction the interaction (contains the message data).
*/
private fun configureReplyIndicator(
conversationViewHolder: ConversationViewHolder,
interaction: Interaction
) {

Pierre Nicolas
committed
val conversation = interaction.conversation
if (conversation == null || conversation !is Conversation) {

Pierre Nicolas
committed
conversationViewHolder.mReplyName?.isVisible = false
conversationViewHolder.mReplyTxt?.isVisible = false

Pierre Nicolas
committed
conversationViewHolder.mReplyBubble?.let { replyBubble ->
val replyTo = interaction.replyTo

Pierre Nicolas
committed
// If currently replying to another message :
conversationViewHolder.compositeDisposable.add(replyTo
.flatMapObservable { reply ->
presenter.contactService
.observeContact(interaction.account!!, reply.contact!!, false)
.map { contact -> Pair(reply, contact) }
}
.observeOn(DeviceUtils.uiScheduler)

Pierre Nicolas
committed
conversationViewHolder.mReplyTxt!!.text = i.first.body
// Name of whom we are replying to.
conversationViewHolder.mReplyName?.text =
if (i.first.isIncoming) i.second.displayName
else res.getString(R.string.conversation_reply_you)
// Apply the correct color depending if message is incoming or not.
val textColor:Int
if (i.first.isIncoming){
textColor = context.getColor(R.color.colorOnSurface)
replyBubble.background.setTint(
context.getColor(R.color.conversation_secondary_background)
}
else {
textColor = context.getColor(R.color.text_color_primary_dark)
replyBubble.background.setTint(convColor)
}
conversationViewHolder.mReplyTxt?.setTextColor(textColor)
conversationViewHolder.mReplyName?.setTextColor(textColor)

Pierre Nicolas
committed
// Load avatar drawable from contact.
val avatarSize = context.resources
.getDimensionPixelSize(R.dimen.conversation_avatar_size_small)

Pierre Nicolas
committed
val smallAvatarDrawable = AvatarDrawable.Builder()
.withContact(i.second)
.withCircleCrop(true)

Pierre Nicolas
committed
// Update the view.
conversationViewHolder.mReplyName!!.setCompoundDrawablesWithIntrinsicBounds(

Pierre Nicolas
committed
replyBubble.isVisible = true
conversationViewHolder.mReplyTxt!!.isVisible = true
// User can click on mReplyTxt (replied message)
// or mReplyName (text above the message) to go to it.
listOf(conversationViewHolder.mReplyTxt,
replyBubble).forEach{
it?.setOnClickListener{
i.first.messageId?.let { presenter.scrollToMessage(it) }

Pierre Nicolas
committed
}) {
replyBubble.isVisible = false

Pierre Nicolas
committed
conversationViewHolder.mReplyTxt!!.isVisible = false
})
} else { // Not replying to another message, we can hide reply Textview.
replyBubble.isVisible = false

Pierre Nicolas
committed
conversationViewHolder.mReplyTxt?.isVisible = false
override fun onBindViewHolder(conversationViewHolder: ConversationViewHolder, position: Int) {
if (isComposing && position == mInteractions.size) {
configureForTypingIndicator(conversationViewHolder)
return
}
val interaction = mInteractions[position]
conversationViewHolder.compositeDisposable.clear()
val animation = AnimationUtils.loadAnimation(conversationViewHolder.itemView.context, R.anim.fade_in)
animation.startOffset = 150
conversationViewHolder.itemView.startAnimation(animation)
conversationViewHolder.mStatusIcon?.let {
configureDisplayIndicator(
conversationViewHolder,
interaction
)
}
conversationViewHolder.mReplyName?.let {
configureReplyIndicator(
conversationViewHolder,
interaction
)
}
conversationViewHolder.reactionChip?.let {
configureReactions(
conversationViewHolder,
interaction
)
}
//Log.w(TAG, "onBindViewHolder $type $interaction");
conversationViewHolder.itemView.visibility = View.GONE
} else {
conversationViewHolder.itemView.visibility = View.VISIBLE
Interaction.InteractionType.TEXT -> configureForTextMessage(
conversationViewHolder,
interaction,
position
)
Interaction.InteractionType.CALL -> configureForCallInfo(
conversationViewHolder,
interaction,
position
)
Interaction.InteractionType.CONTACT -> configureForContactEvent(
conversationViewHolder,
)
Interaction.InteractionType.DATA_TRANSFER -> configureForFileInfo(
conversationViewHolder,
interaction,
position
)
configureSearchResult(conversationViewHolder, interaction)
}
override fun onViewRecycled(holder: ConversationViewHolder) {
holder.itemView.setOnLongClickListener(null)
if (holder.mImage != null) {
holder.mImage.setOnLongClickListener(null)
}
if (holder.video != null) {
holder.video.setOnClickListener(null)
holder.video.surfaceTextureListener = null
}
holder.surface?.release()
holder.surface = null
holder.player?.let { player ->
if (player.isPlaying) player.stop()
player.reset()
} catch (e: Exception) {
// left blank intentionally
}
holder.mMsgTxt?.setOnLongClickListener(null)
holder.mItem?.setOnClickListener(null)
holder.compositeDisposable.clear()
}
convColorTint =
MaterialColors.compositeARGBWithAlpha(color, (MaterialColors.ALPHA_LOW * 255).toInt())
fun getPrimaryColor() = convColor
fun setComposingStatus(composingStatus: ComposingStatus) {
val composing = composingStatus == ComposingStatus.Active
if (isComposing != composing) {
isComposing = composing
if (composing) notifyItemInserted(mInteractions.size) else notifyItemRemoved(
mInteractions.size
)
}
}
private class RecyclerViewContextMenuInfo(
val position: Int,
val id: Long
) : ContextMenuInfo
private fun addToClipboard(text: String?) {
if (text.isNullOrEmpty()) return
val clipboard = conversationFragment.requireActivity()
.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("Copied Message", text))
private fun configureImage(
viewHolder: ConversationViewHolder,
path: File,
displayName: String?
) {
val image = viewHolder.mImage ?: return
image.clipToOutline = true
.transition(withCrossFade())
.into(image)
image.setOnClickListener { v: View ->
val contentUri =
getUriForFile(v.context, ContentUriHandler.AUTHORITY_FILES, path, displayName)
val i = Intent(context, MediaViewerActivity::class.java)
.setAction(Intent.ACTION_VIEW)
.setDataAndType(contentUri, "image/*")
.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
val options = ActivityOptionsCompat.makeSceneTransitionAnimation(
conversationFragment.requireActivity(),
viewHolder.mImage,
"picture"
)
conversationFragment.startActivityForResult(i, 3006, options.toBundle())
} catch (e: Exception) {
Log.w(TAG, "Can't open picture", e)
}
}
}
private fun configureAudio(viewHolder: ConversationViewHolder, path: File) {
val context = viewHolder.itemView.context
try {
val acceptBtn = viewHolder.btnAccept as ImageView
val refuseBtn = viewHolder.btnRefuse!!
acceptBtn.setImageResource(R.drawable.baseline_play_arrow_24)
val player = MediaPlayer.create(
context,
getUriForFile(context, ContentUriHandler.AUTHORITY_FILES, path)
)
viewHolder.player = player
if (player != null) {
player.setOnCompletionListener { mp: MediaPlayer ->
mp.seekTo(0)
acceptBtn.setImageResource(R.drawable.baseline_play_arrow_24)
if (player.isPlaying) {
player.pause()
acceptBtn.setImageResource(R.drawable.baseline_play_arrow_24)
acceptBtn.setImageResource(R.drawable.baseline_pause_24)
if (player.isPlaying) player.pause()
player.seekTo(0)
acceptBtn.setImageResource(R.drawable.baseline_play_arrow_24)
}
viewHolder.compositeDisposable.add(
Observable.interval(1L, TimeUnit.SECONDS, DeviceUtils.uiScheduler)
val pS = player.currentPosition / 1000
val dS = player.duration / 1000
viewHolder.mMsgTxt?.text = String.format(
Locale.getDefault(),
"%02d:%02d / %02d:%02d",
pS / 60,
pS % 60,
dS / 60,
dS % 60
)
acceptBtn.setOnClickListener(null)
refuseBtn.setOnClickListener(null)
}
} catch (e: Exception) {
Log.e(TAG, "Error initializing player", e)
}
}
private fun configureVideo(viewHolder: ConversationViewHolder, path: File) {
val context = viewHolder.itemView.context
viewHolder.player?.let {
viewHolder.player = null
it.release()
}
val video = viewHolder.video ?: return
val cardLayout = viewHolder.mLayout as CardView
val contentUri = try {
getUriForFile(context, ContentUriHandler.AUTHORITY_FILES, path)
} catch (e: Exception) {
Log.w(TAG, "Can't open video", e)
return
}
val player = MediaPlayer.create(context, contentUri) ?: return
val playBtn =
ContextCompat.getDrawable(cardLayout.context, R.drawable.baseline_play_arrow_24)!!
.mutate()
DrawableCompat.setTint(playBtn, Color.WHITE)
player.setOnCompletionListener { mp: MediaPlayer ->
if (mp.isPlaying) mp.pause()
mp.seekTo(1)
player.setOnVideoSizeChangedListener { _: MediaPlayer, width: Int, height: Int ->
Log.w(TAG, "OnVideoSizeChanged " + width + "x" + height)
val p = video.layoutParams as FrameLayout.LayoutParams
p.width = width * mPictureMaxSize / maxDim
p.height = height * mPictureMaxSize / maxDim
} else {
p.width = 0
p.height = 0
}
}
player.setSurface(viewHolder.surface)
}
video.surfaceTextureListener = object : SurfaceTextureListener {
override fun onSurfaceTextureAvailable(
surfaceTexture: SurfaceTexture,
width: Int,
height: Int
) {
viewHolder.surface = Surface(surfaceTexture).also { surface ->
try {
player.setSurface(surface)
} catch (e: Exception) {
// Left blank
}
override fun onSurfaceTextureSizeChanged(
surface: SurfaceTexture,
width: Int,
height: Int
) {
}
override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean {
try {
player.setSurface(null)
} catch (e: Exception) {
// Left blank
}
viewHolder.surface?.let {
viewHolder.surface = null
it.release()
}
return true
}
override fun onSurfaceTextureUpdated(surface: SurfaceTexture) {}
}
try {
if (player.isPlaying) {
player.pause()
(viewHolder.mLayout as CardView).foreground = playBtn
} else {
player.start()
(viewHolder.mLayout as CardView).foreground = null
}
} catch (e: Exception) {
// Left blank
}
}
player.seekTo(1)
}
/**
* Display and manage the popup that allows user to react/reply/share/edit/delete message ...
*
* @param conversationViewHolder the view layout.
* @param view
* @param interaction
*/
private fun openItemMenu(
conversationViewHolder: ConversationViewHolder, view: View, interaction: Interaction
) {
// Inflate design from XML.
MenuConversationBinding.inflate(LayoutInflater.from(view.context)).apply {
val history = interaction.historyObservable.blockingFirst()
val lastElement = history.last()
val isDeleted = lastElement is TextMessage && lastElement.body.isNullOrEmpty()
// Configure what should be displayed
convActionOpenText.isVisible = interaction is DataTransfer && interaction.isComplete
convActionDownloadText.isVisible = interaction is DataTransfer && interaction.isComplete
convActionCopyText.isVisible = !isDeleted && interaction !is DataTransfer
convActionEdit.isVisible =
!isDeleted && !interaction.isIncoming && interaction !is DataTransfer
convActionDelete.isVisible = !isDeleted && !interaction.isIncoming
convActionHistory.isVisible = !isDeleted && history.size > 1
root.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED)
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
// The popup that display all the buttons
val popupWindow = PopupWindow(
root, LinearLayout.LayoutParams.WRAP_CONTENT, root.measuredHeight, true
)
.apply {
elevation = view.context.resources.getDimension(R.dimen.call_preview_elevation)
showAsDropDown(view)
}
val textViews = listOf(
convActionEmoji1.chip, convActionEmoji2.chip,
convActionEmoji3.chip, convActionEmoji4.chip
)
convActionEmoji1.chip.text = view.context.getString(R.string.default_emoji_1)
convActionEmoji2.chip.text = view.context.getString(R.string.default_emoji_2)
convActionEmoji3.chip.text = view.context.getString(R.string.default_emoji_3)
convActionEmoji4.chip.text = view.context.getString(R.string.default_emoji_4)
// Subscribe on reactions to allows user to see which reaction he already selected.
val disposable = interaction.reactionObservable
.observeOn(DeviceUtils.uiScheduler)
.subscribe { reactions ->
textViews.forEach { textView ->
// Set checked reactions already sent.
textView.isChecked = reactions.any {
(textView.text == it.body) && (it.contact?.isUser == true)
}
conversationViewHolder.compositeDisposable.add(disposable)
popupWindow.setOnDismissListener {
val type = conversationViewHolder.type.transferType
if (convColor != 0 && (interaction.type == Interaction.InteractionType.TEXT
|| type == MessageType.TransferType.FILE
|| type == MessageType.TransferType.AUDIO ) && !interaction.isIncoming
) view.background?.setTint(convColor)
else view.background?.setTintList(null)
// Remove disposable.
conversationViewHolder.compositeDisposable.remove(disposable)
}
// Callback executed when emoji is clicked.
// We want to know if the reaction is already set.
// If set we want to remove, else we want to append.
val emojiCallback = View.OnClickListener { view ->
// Subscribe to know which are the current reactions.
conversationViewHolder.compositeDisposable.add(interaction.reactionObservable
.observeOn(DeviceUtils.uiScheduler)
.firstOrError()
.subscribe { reactions ->
// Try to find a reaction having corresponding to the one clicked.
val reaction = reactions.firstOrNull {
(it.body == (view as TextView).text) && (it.contact?.isUser == true)
}
if (reaction != null)
// Previously, it was not forbidden to send multiple times the same
// reaction. Hence, we only remove the first one.
presenter.removeReaction(reaction)
else // If null, it means we didn't find anything. So let's send it.
presenter.sendReaction(interaction, (view as TextView).text)
popupWindow.dismiss()
}
)
textViews.forEach { it.setOnClickListener(emojiCallback) } // Set callback
// Configure reply
convActionReply.setOnClickListener {
presenter.startReplyTo(interaction)
emojiPicker.setOnEmojiPickedListener {
presenter.sendReaction(interaction, it.emoji)
popupWindow.dismiss()
}
convActionMore.setOnClickListener {
val newState = menuActions.isVisible
menuActions.isVisible = !newState
emojiPicker.isVisible = newState
popupWindow.let {
root.measure(root.width, View.MeasureSpec.UNSPECIFIED)
it.height = root.measuredHeight
it.dismiss()
it.showAsDropDown(view)
}
convActionOpenText.setOnClickListener {
presenter.openFile(interaction)
}
convActionDownloadText.setOnClickListener {
presenter.saveFile(interaction)
}
convActionCopyText.setOnClickListener {
// Manage Edit and Delete actions
convActionEdit.setOnClickListener {
try {
val i = Intent(it.context, MessageEditActivity::class.java)
.setData(
Uri.withAppendedPath(
ConversationPath.toUri(
interaction.account!!,
interaction.conversationId!!
), interaction.messageId
)
)
.putExtra(
Intent.EXTRA_TEXT,
conversationViewHolder.mMessageContent!!.getText().toString()
)
val options = ActivityOptionsCompat.makeSceneTransitionAnimation(
conversationFragment.requireActivity(),
conversationViewHolder.mMessageBubble!!,
"messageEdit"
)
conversationFragment.startActivityForResult(
i,