Code owners
Assign users and groups as approvers for specific file changes. Learn more.
ConversationActionsFragment.kt 17.63 KiB
/*
* Copyright (C) 2004-2024 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.fragments
import android.content.ClipData
import android.content.ClipboardManager
import android.content.DialogInterface
import android.content.res.ColorStateList
import android.graphics.Color
import android.graphics.drawable.Drawable
import android.os.Bundle
import android.util.Log
import android.view.*
import android.widget.Toast
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import cx.ring.R
import cx.ring.client.ColorChooserBottomSheet
import cx.ring.client.ContactDetailsActivity
import cx.ring.client.EmojiChooserBottomSheet
import cx.ring.databinding.FragConversationActionsBinding
import cx.ring.databinding.ItemContactActionBinding
import cx.ring.interfaces.Colorable
import cx.ring.services.SharedPreferencesServiceImpl.Companion.getConversationColor
import cx.ring.services.SharedPreferencesServiceImpl.Companion.getConversationSymbol
import cx.ring.utils.ConversationPath
import cx.ring.utils.DeviceUtils
import dagger.hilt.android.AndroidEntryPoint
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import net.jami.model.Conversation
import net.jami.model.Uri
import net.jami.services.AccountService
import net.jami.services.ConversationFacade
import javax.inject.Inject
import javax.inject.Singleton
@AndroidEntryPoint
class ConversationActionsFragment : Fragment(), Colorable {
@Inject
@Singleton
lateinit
var mConversationFacade: ConversationFacade
@Inject
@Singleton lateinit
var mAccountService: AccountService
private var binding: FragConversationActionsBinding? = null
private val mDisposableBag = CompositeDisposable()
private val adapter = ContactActionAdapter(mDisposableBag)
private var colorAction: ContactAction? = null
private var symbolAction: ContactAction? = null
private var colorActionPosition = 0
private var symbolActionPosition = 0
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,savedInstanceState: Bundle?
): View =
// Inflate the layout for this fragment
FragConversationActionsBinding.inflate(inflater, container, false).apply {
// Init some variables useful for the fragment
val path = ConversationPath.fromBundle(arguments)!!
// Get the current conversation
val conversation = mConversationFacade
.startConversation(path.accountId, path.conversationUri)
.blockingGet()
colorActionPosition = 0
symbolActionPosition = 1
if (conversation.mode.blockingFirst().isSwarm) {
// Setup an action to allow the user to select a color for the conversation.
colorAction = ContactAction(
R.drawable.item_color_background, 0,
getText(R.string.conversation_preference_color)
) {
ColorChooserBottomSheet { color -> // Color chosen by the user (onclick method).
val rgbColor = String.format("#%06X", 0xFFFFFF and color)
mConversationFacade.setConversationPreferences(
path.accountId,
path.conversationUri,
mapOf(Conversation.KEY_PREFERENCE_CONVERSATION_COLOR to rgbColor)
)
// Need to manually update the color of the conversation as will not get the
// update signal from daemon.
if (!path.conversationUri.isSwarm) conversation.setColor(color)
}.show(parentFragmentManager, "colorChooser")
}
// Add the action to the list of actions.
adapter.actions.add(colorAction!!)
// Setup an action to allow the user to select an Emoji for the conversation.
symbolAction = ContactAction(0, getText(R.string.conversation_preference_emoji)) {
EmojiChooserBottomSheet { emoji -> // Emoji chosen by the user (onclick method).
if (emoji == null) return@EmojiChooserBottomSheet
mConversationFacade.setConversationPreferences(
path.accountId,
path.conversationUri,
mapOf(Conversation.KEY_PREFERENCE_CONVERSATION_SYMBOL to emoji)
)
// Need to manually update the symbol of the conversation as will not get the
// update signal from daemon.
if(!path.conversationUri.isSwarm) conversation.setSymbol(emoji.toString())
}.show(parentFragmentManager, "colorChooser")
}
adapter.actions.add(symbolAction!!)
}
// Update color on RX color signal.
mDisposableBag.add(conversation.getColor()
.observeOn(DeviceUtils.uiScheduler)
.subscribe { setColor(getConversationColor(requireContext(), it)) })
// Update symbol on RX color signal.
mDisposableBag.add(conversation.getSymbol()
.observeOn(DeviceUtils.uiScheduler)
.subscribe { setSymbol(getConversationSymbol(requireContext(), it)) })
// Setup card with
// - conversation type (such as "Private swarm")
// - conversation id (such as swarm:1234)"
// The real conversation mode is hidden in TrustRequest when it's a request.
val conversationMode =
if (conversation.mode.blockingFirst() == Conversation.Mode.Request)
conversation.request!!.mode
else conversation.mode.blockingFirst()
@StringRes val infoString =
if (conversation.isSwarm) {
if (conversationMode == Conversation.Mode.OneToOne)
R.string.conversation_type_private
else {
R.string.conversation_type_group
}
}
else R.string.conversation_type_contact
conversationType.setText(infoString)
val conversationUri = conversation.uri.toString()
conversationId.text = conversationUri
// Onclick on the card, copy the conversation id to the clipboard and show a snack-bar
infoCard.setOnClickListener { copyAndShow(path.conversationId) }
// Setup other actions depending on context
val callUri: Uri?
if (conversationMode == Conversation.Mode.OneToOne) {
callUri = conversation.contact!!.uri
if (!conversation.contact!!.isBanned) {
// Setup block contact action
adapter.actions.add(
ContactAction(
R.drawable.baseline_block_24,
getText(R.string.conversation_action_block_this)
) {
// Callback when the block action is clicked
// Display a dialog to confirm the action
MaterialAlertDialogBuilder(requireContext())
.setTitle(getString(R.string.block_contact_dialog_title, conversationUri))
.setMessage(getString(R.string.block_contact_dialog_message, conversationUri))
.setPositiveButton(R.string.conversation_action_block_this){ _: DialogInterface?, _: Int ->
// Ban conversation and display a toast to display success.
mConversationFacade.banConversation(conversation.accountId, conversation.uri)
Toast.makeText(
requireContext(),
getString(R.string.block_contact_completed, conversationUri),
Toast.LENGTH_LONG
).show()
requireActivity().finish()
}
.setNegativeButton(android.R.string.cancel, null)
.create()
.show()
}
)
} else {
// Setup unblock contact action
adapter.actions.add(
ContactAction(
R.drawable.baseline_person_add_24,
getText(R.string.conversation_action_unblock_this)
) {
// Callback when the unblock action is clicked
// Display a dialog to confirm the action
MaterialAlertDialogBuilder(requireContext())
.setTitle(getString(R.string.unblock_contact_dialog_title, conversationUri))
.setMessage(getString(R.string.unblock_contact_dialog_message, conversationUri))
.setPositiveButton(R.string.conversation_action_unblock_this) { _: DialogInterface?, _: Int ->
// Add contact and display a toast to display success.
mAccountService.addContact(conversation.accountId, callUri.rawRingId)
Toast.makeText(
requireContext(),
getString(R.string.unblock_contact_completed, conversationUri),
Toast.LENGTH_LONG).show()
requireActivity().finish()
}
.setNegativeButton(android.R.string.cancel, null)
.create()
.show()
}
)
}
} else { // If conversation mode is not one to one
callUri = conversation.uri
}
if (!conversation.isSwarm) {
// Setup clear history action
adapter.actions.add(
ContactAction(
R.drawable.baseline_clear_all_24,
getText(R.string.conversation_action_history_clear)
) {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.clear_history_dialog_title)
.setMessage(R.string.clear_history_dialog_message)
.setPositiveButton(R.string.conversation_action_history_clear) { _: DialogInterface?, _: Int ->
// Clear history and display a snack-bar to display success.
mConversationFacade.clearHistory(conversation.accountId, callUri).subscribe()
Snackbar.make(
root, R.string.clear_history_completed, Snackbar.LENGTH_LONG
).show()
}
.setNegativeButton(android.R.string.cancel, null)
.create()
.show()
}
)
}
// Setup call actions (audio only)
adapter.actions.add(
ContactAction(R.drawable.baseline_call_24, getText(R.string.ab_action_audio_call)) {
(activity as ContactDetailsActivity)
.goToCallActivity(conversation, callUri, false)
}
)
// Setup video call action
adapter.actions.add(
ContactAction(
R.drawable.baseline_videocam_24, getText(R.string.ab_action_video_call)
) {
(activity as ContactDetailsActivity)
.goToCallActivity(conversation, callUri, true)
}
)
contactActionList.adapter = adapter
binding = this
}.root
override fun onDestroy() {
adapter.actions.clear()
mDisposableBag.dispose()
super.onDestroy()
colorAction = null
binding = null
}
/**
* Set the color of the color action button.
*/
override fun setColor(color: Int) {
colorAction?.iconTint = color
adapter.notifyItemChanged(colorActionPosition)
}
/**
* Set the symbol of the symbol action button.
*/
private fun setSymbol(symbol: CharSequence) {
symbolAction?.setSymbol(symbol) // Update emoji action icon
adapter.notifyItemChanged(symbolActionPosition)
}
private fun copyAndShow(toCopy: String) {
val clipboard = requireActivity().getSystemService(AppCompatActivity.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText(getText(R.string.clip_contact_uri), toCopy))
Snackbar.make(binding!!.root, getString(R.string.conversation_action_copied_peer_number_clipboard, toCopy), Snackbar.LENGTH_LONG).show()
}
internal class ContactAction {
@DrawableRes
val icon: Int
val drawable: Single<Drawable>?
val title: CharSequence
val callback: () -> Unit
var iconTint: Int
var iconSymbol: CharSequence? = null
constructor(@DrawableRes i: Int, tint: Int, t: CharSequence, cb: () -> Unit) {
icon = i
iconTint = tint
title = t
callback = cb
drawable = null
}
constructor(@DrawableRes i: Int, t: CharSequence, cb: () -> Unit) {
icon = i
iconTint = Color.BLACK
title = t
callback = cb
drawable = null
}
constructor(d: Single<Drawable>?, t: CharSequence, cb: () -> Unit) {
drawable = d
icon = 0
iconTint = Color.BLACK
title = t
callback = cb
}
fun setSymbol(t: CharSequence?) {
iconSymbol = t
}
}
internal class ContactActionView(
val binding: ItemContactActionBinding,
parentDisposable: CompositeDisposable
) : RecyclerView.ViewHolder(binding.root) {
var callback: (() -> Unit)? = null
val disposable = CompositeDisposable()
init {
parentDisposable.add(disposable)
itemView.setOnClickListener {
try {
callback?.invoke()
} catch (e: Exception) {
Log.w(TAG, "Error performing action", e)
}
}
}
}
private class ContactActionAdapter(private val disposable: CompositeDisposable) :
RecyclerView.Adapter<ContactActionView>() {
val actions = ArrayList<ContactAction>()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ContactActionView {
val layoutInflater = LayoutInflater.from(parent.context)
val itemBinding = ItemContactActionBinding.inflate(layoutInflater, parent, false)
return ContactActionView(itemBinding, disposable)
}
override fun onBindViewHolder(holder: ContactActionView, position: Int) {
val action = actions[position]
holder.disposable.clear()
if (action.drawable != null) {
holder.disposable.add(action.drawable.subscribe { background: Drawable ->
holder.binding.actionIcon.background = background
})
} else {
holder.binding.actionIcon.setBackgroundResource(action.icon)
holder.binding.actionIcon.text = action.iconSymbol
if (action.iconTint != Color.BLACK) ViewCompat.setBackgroundTintList(
holder.binding.actionIcon,
ColorStateList.valueOf(action.iconTint)
)
}
holder.binding.actionTitle.text = action.title
holder.callback = action.callback
}
override fun onViewRecycled(holder: ContactActionView) {
holder.disposable.clear()
holder.binding.actionIcon.background = null
}
override fun getItemCount(): Int {
return actions.size
}
}
companion object {
private val TAG = ConversationActionsFragment::class.simpleName!!
fun newInstance(accountId: String, conversationId: Uri) = ConversationActionsFragment().apply {
arguments = ConversationPath.toBundle(accountId, conversationId)
}
}
}