diff --git a/jami-android/app/src/main/java/cx/ring/adapters/ConversationAdapter.kt b/jami-android/app/src/main/java/cx/ring/adapters/ConversationAdapter.kt index 6c0a7040f32a684f44dfb2750b24f6177b5f84d0..538294d60262656016d8e229faabc293fcd92f53 100644 --- a/jami-android/app/src/main/java/cx/ring/adapters/ConversationAdapter.kt +++ b/jami-android/app/src/main/java/cx/ring/adapters/ConversationAdapter.kt @@ -318,26 +318,62 @@ class ConversationAdapter( conversationViewHolder: ConversationViewHolder, interaction: Interaction ) { + val statusIcon = conversationViewHolder.mStatusIcon ?: return + val messageToAttach = conversationViewHolder.mLayoutStatusIconId?.id ?: return + val conversation = interaction.conversation if (conversation == null || conversation !is Conversation) { - conversationViewHolder.mStatusIcon?.isVisible = false + statusIcon.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 - ) - }) + + // Attach the statusIcon to the message layout. + statusIcon.attachToMessage(messageToAttach) + + // Remove user from statusMap. + val modifiedStatusMap = interaction.statusMap + .filter { !conversation.findContact(net.jami.model.Uri.fromId(it.key))!!.isUser } + + val isDisplayed = modifiedStatusMap.any { it.value == Interaction.MessageStates.DISPLAYED } + val isSending = modifiedStatusMap.isEmpty() or + modifiedStatusMap.any { it.value == Interaction.MessageStates.SENDING } + val isReceived = modifiedStatusMap.any { it.value == Interaction.MessageStates.SUCCESS } + val lastDisplayedIdx = conversation.lastDisplayedMessages + .map { conversation.getMessage(it.value) } + .maxOfOrNull { mInteractions.indexOf(it) } + val currentIdx = mInteractions.indexOf(interaction) + + // Case 1: Message is sending + if(!isDisplayed && isSending){ + statusIcon.let { + it.visibility = View.VISIBLE + it.updateSending() + } + } + // Case 2: Message is received by at least one contact + else if(!isDisplayed && isReceived){ + statusIcon.let { + it.visibility = View.VISIBLE + it.updateSuccess() + } + } + // Case 3: Message is displayed + else if (isDisplayed && currentIdx == lastDisplayedIdx) { + conversationViewHolder.compositeDisposable.add( + presenter.conversationFacade + .getLoadedContact( + accountId = interaction.account!!, + conversation = conversation, + contactIds = modifiedStatusMap.map { it.key } + ) + .observeOn(DeviceUtils.uiScheduler) + .subscribe { seenBy -> + statusIcon.visibility = View.VISIBLE + statusIcon.updateDisplayed(seenBy) + }) + } + // Case 4: Message is displayed but not the last displayed message + else statusIcon.visibility = View.GONE } /** diff --git a/jami-android/app/src/main/java/cx/ring/views/MessageStatusView.kt b/jami-android/app/src/main/java/cx/ring/views/MessageStatusView.kt index 05f886b68b5450d0f55b501663d73c8a9bfbb804..a7f464181c3b64d101412baf50efd0341800b2aa 100644 --- a/jami-android/app/src/main/java/cx/ring/views/MessageStatusView.kt +++ b/jami-android/app/src/main/java/cx/ring/views/MessageStatusView.kt @@ -32,95 +32,117 @@ import net.jami.model.ContactViewModel import net.jami.model.Interaction import net.jami.utils.Log +/** + * MessageStatusView display the status of a message (sending, sent, displayed). + * Sending and sent Status are displayed as icons, while Displayed status is displayed as avatars. + */ class MessageStatusView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyle: Int = 0, ) : LinearLayout(context, attrs, defStyle) { + @IdRes + private var attachedMessage: Int = View.NO_ID private val iconSize = resources.getDimensionPixelSize(R.dimen.conversation_status_icon_size) private val iconTint: ColorStateList = ColorStateList.valueOf(ContextCompat.getColor(context, R.color.grey_500)) + // Add or remove views to match the given count. + // "Sending" or "Sent" need 1 view, "Displayed" needs as many views as there are contacts. private fun resize(count: Int) { - if (count == 0) { - removeAllViews() - } else if (childCount > count) { - while (childCount > count) { - removeViewAt(childCount - 1) - } - } else if (childCount < count) { - var i = childCount - while (childCount < count) { - addView(ImageView(context).apply { - layoutParams = LayoutParams(iconSize, iconSize).apply { - marginStart = if (i != 0) -iconSize/3 else 0 - } - }) - i++ - } - } - } + if (count == childCount) return - fun update(contacts: Collection<ContactViewModel>, status: Interaction.InteractionStatus, @IdRes resId: Int = View.NO_ID) { - val showStatus = contacts.isEmpty() && (status == Interaction.InteractionStatus.SUCCESS || status == Interaction.InteractionStatus.SENDING) - if (showStatus) { - resize(1) - (getChildAt(0) as ImageView).apply { - setImageResource(when (status) { - Interaction.InteractionStatus.FAILURE -> R.drawable.round_highlight_off_24 - Interaction.InteractionStatus.SENDING -> R.drawable.baseline_check_circle_outline_24 - Interaction.InteractionStatus.SUCCESS -> R.drawable.baseline_check_circle_24 - else -> -1 //R.drawable.baseline_check_circle_24 - }) - ImageViewCompat.setImageTintList(this, iconTint) - } - } else { - resize(contacts.size) - contacts.forEachIndexed { index, contact -> - (getChildAt(index) as ImageView).apply { - imageTintList = null - setImageDrawable(AvatarDrawable.Builder() - .withCircleCrop(true) - .withContact(contact) - .withPresence(false) - .build(context)) + // Update layout only if there is a change in the mode (empty, single or multiple). + if (childCount == 0 || (count == 1 && childCount > 1) || (count > 1 && childCount == 1)) + layout(count) + + if (count == 0) removeAllViews() + else if (childCount > count) while (childCount > count) removeViewAt(childCount - 1) + else if (childCount < count) repeat(count - childCount) { + addView(ImageView(context).apply { + layoutParams = LayoutParams(iconSize, iconSize).apply { + marginStart = if (it != 0) -iconSize / 3 else 0 } - } + }) } + } + + // Layout the views depending on the count and the layout type. + // If one view is displayed, it is put on the right of the message. + // If multiple views are displayed, they are put below the message. + private fun layout(count: Int) { + val fitRight = count < 2 when (layoutParams) { is RelativeLayout.LayoutParams -> { val params = layoutParams as RelativeLayout.LayoutParams? ?: return - val fitRight = showStatus || contacts.size < 2 if (fitRight) { - // Put the avatar on the right of the message if there is only one contact + // Put the view on the right of the message. params.removeRule(RelativeLayout.BELOW) - params.addRule(RelativeLayout.ALIGN_BOTTOM, resId) + params.addRule(RelativeLayout.ALIGN_BOTTOM, attachedMessage) } else { - // Put the avatars below the message if there are multiple contacts + // Put the view below the message. params.removeRule(RelativeLayout.ALIGN_BOTTOM) - params.addRule(RelativeLayout.BELOW, resId) + params.addRule(RelativeLayout.BELOW, attachedMessage) } layoutParams = params } + is ConstraintLayout.LayoutParams -> { val params = layoutParams as ConstraintLayout.LayoutParams? ?: return - val fitRight = showStatus || contacts.size < 2 if (fitRight) { - // Put the avatar on the right of the message if there is only one contact + // Put the view on the right of the message. params.bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID params.topToBottom = ConstraintLayout.LayoutParams.UNSET } else { - // Put the avatars below the message if there are multiple contacts + // Put the view below the message. params.bottomToBottom = ConstraintLayout.LayoutParams.UNSET - params.topToBottom = resId + params.topToBottom = attachedMessage } layoutParams = params } + else -> Log.w(TAG, "Error layout params.") } } + fun attachToMessage(@IdRes resId: Int) { + attachedMessage = resId + layout(childCount) + } + + fun updateSending() { + resize(1) + (getChildAt(0) as ImageView).apply { + setImageResource(R.drawable.sent) + ImageViewCompat.setImageTintList(this, iconTint) + } + } + + fun updateSuccess() { + resize(1) + (getChildAt(0) as ImageView).apply { + setImageResource(R.drawable.receive) + ImageViewCompat.setImageTintList(this, iconTint) + } + } + + fun updateDisplayed(seenBy: Collection<ContactViewModel>) { + resize(seenBy.size) + seenBy.forEachIndexed { index, contact -> + (getChildAt(index) as ImageView).apply { + imageTintList = null + setImageDrawable( + AvatarDrawable.Builder() + .withCircleCrop(true) + .withContact(contact) + .withPresence(false) + .build(context) + ) + } + } + } + companion object { val TAG = MessageStatusView::class.simpleName!! } diff --git a/jami-android/app/src/main/res/drawable/receive.xml b/jami-android/app/src/main/res/drawable/receive.xml new file mode 100644 index 0000000000000000000000000000000000000000..7dd4db924fd7a288df3408bf2399943c864d1d33 --- /dev/null +++ b/jami-android/app/src/main/res/drawable/receive.xml @@ -0,0 +1,13 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="12dp" + android:height="12dp" + android:viewportWidth="12" + android:viewportHeight="12"> + <group> + <clip-path + android:pathData="M0,0h12v12h-12z"/> + <path + android:pathData="M6.43,8.784 L3.007,5.362 4.06,4.309l2.37,2.37 4.314,-4.314A5.966,5.966 0,0 0,6 0c-0.032,0 -0.061,0.008 -0.094,0.01A5.98,5.98 0,0 0,0.094 5.074,5.911 5.911,0 0,0 0,6a5.911,5.911 0,0 0,0.094 0.926A5.98,5.98 0,0 0,5.906 11.99c0.032,0 0.061,0.01 0.094,0.01a6,6 0,0 0,5.533 -8.32Z" + android:fillColor="#60c880"/> + </group> +</vector> diff --git a/jami-android/app/src/main/res/drawable/sent.xml b/jami-android/app/src/main/res/drawable/sent.xml new file mode 100644 index 0000000000000000000000000000000000000000..f407a6a04a5151a871c56b654b77f15bd0235ad8 --- /dev/null +++ b/jami-android/app/src/main/res/drawable/sent.xml @@ -0,0 +1,13 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="12dp" + android:height="12dp" + android:viewportWidth="12" + android:viewportHeight="12"> + <path + android:pathData="M6,6m-5.25,0a5.25,5.25 0,1 1,10.5 0a5.25,5.25 0,1 1,-10.5 0" + android:strokeAlpha="0.5" + android:strokeWidth="1.5" + android:fillColor="#00000000" + android:strokeColor="#fff" + android:fillAlpha="0.5"/> +</vector> diff --git a/jami-android/libjamiclient/src/main/kotlin/net/jami/model/Conversation.kt b/jami-android/libjamiclient/src/main/kotlin/net/jami/model/Conversation.kt index f08fbb2226ed8a6801be674839d2a4719a75079e..04b3c070f39b3484efaa4071949a6ded7188943c 100644 --- a/jami-android/libjamiclient/src/main/kotlin/net/jami/model/Conversation.kt +++ b/jami-android/libjamiclient/src/main/kotlin/net/jami/model/Conversation.kt @@ -35,7 +35,7 @@ class Conversation : ConversationHistory { private val currentCalls = ArrayList<Conference>() val aggregateHistory = ArrayList<Interaction>(32) - private val lastDisplayedMessages: MutableMap<String, String> = HashMap() + val lastDisplayedMessages: MutableMap<String, String> = HashMap() private val updatedElementSubject: Subject<Pair<Interaction, ElementStatus>> = PublishSubject.create() private val clearedSubject: Subject<List<Interaction>> = PublishSubject.create() private val callsSubject: Subject<List<Conference>> = BehaviorSubject.createDefault(emptyList()) @@ -358,8 +358,20 @@ class Conversation : ConversationHistory { updatedElementSubject.onNext(Pair(e, ElementStatus.UPDATE)) } } - // Add contact to new displayed interaction - lastDisplayedMessages[contactId] = messageId + + // Check if the new message is after the last displayed message (could be not the case). + val currentLastMessageDisplayed: Interaction? = + lastDisplayedMessages[contactId]?.let { getMessage(it) } + val newPotentialMessageDisplayed = getMessage(messageId) + val isAfter = + if (currentLastMessageDisplayed != null && newPotentialMessageDisplayed != null) { + isAfter(currentLastMessageDisplayed, newPotentialMessageDisplayed) + } else false + + // Update the last displayed message + if (isAfter or (currentLastMessageDisplayed == null)) + lastDisplayedMessages[contactId] = messageId + mMessages[messageId]?.let { e -> e.displayedContacts.add(contactId) updatedElementSubject.onNext(Pair(e, ElementStatus.UPDATE)) @@ -367,18 +379,23 @@ class Conversation : ConversationHistory { } @Synchronized - fun updateSwarmInteraction(messageId: String, contactUri: Uri, newStatus: Interaction.InteractionStatus) { - val e = mMessages[messageId] ?: return - if (newStatus == Interaction.InteractionStatus.DISPLAYED) { - Log.w(TAG, "updateSwarmInteraction DISPLAYED") + fun updateSwarmInteraction( + messageId: String, + contactUri: Uri, + newStatus: Interaction.MessageStates, + ) { + val interaction = mMessages[messageId] ?: return + if (newStatus == Interaction.MessageStates.DISPLAYED) { findContact(contactUri)?.let { contact -> if (!contact.isUser) setLastMessageDisplayed(contactUri.host, messageId) } - } else if (newStatus != Interaction.InteractionStatus.SENDING) { - e.status = newStatus - updatedElementSubject.onNext(Pair(e, ElementStatus.UPDATE)) + } else if (newStatus != Interaction.MessageStates.SENDING) { + interaction.status = Interaction.InteractionStatus.SENDING } + + interaction.statusMap = interaction.statusMap.plus(Pair(contactUri.host, newStatus)) + updatedElementSubject.onNext(Pair(interaction, ElementStatus.UPDATE)) } @Synchronized diff --git a/jami-android/libjamiclient/src/main/kotlin/net/jami/model/Interaction.kt b/jami-android/libjamiclient/src/main/kotlin/net/jami/model/Interaction.kt index f94253735f673a1e603c598e1ecb6d80c40722e7..789b5ae85fe576beeb9bc8702307ec5ecaffc89b 100644 --- a/jami-android/libjamiclient/src/main/kotlin/net/jami/model/Interaction.kt +++ b/jami-android/libjamiclient/src/main/kotlin/net/jami/model/Interaction.kt @@ -36,6 +36,7 @@ open class Interaction { var reactToId: String? = null var reactions: MutableList<Interaction> = ArrayList() var history: MutableList<Interaction> = ArrayList<Interaction>(1).apply { add(this@Interaction) } + var statusMap: Map<String, MessageStates> = emptyMap() private var historySubject: Subject<List<Interaction>> = BehaviorSubject.createDefault(history) diff --git a/jami-android/libjamiclient/src/main/kotlin/net/jami/services/AccountService.kt b/jami-android/libjamiclient/src/main/kotlin/net/jami/services/AccountService.kt index 84dec240669bd5dbe61ec79d629f7705adaab317..8f81638135d436a7e3cab15b57828feb1f44dbbb 100644 --- a/jami-android/libjamiclient/src/main/kotlin/net/jami/services/AccountService.kt +++ b/jami-android/libjamiclient/src/main/kotlin/net/jami/services/AccountService.kt @@ -1016,18 +1016,27 @@ class AccountService( incomingMessageSubject.onNext(Message(accountId, messageId, callId, from, messages)) } - fun accountMessageStatusChanged(accountId: String, conversationId: String, messageId: String, contactId: String, status: Int) { - val newStatus = InteractionStatus.fromIntTextMessage(status) - Log.d(TAG, "accountMessageStatusChanged: $accountId, $conversationId, $messageId, $contactId, $newStatus") + fun accountMessageStatusChanged( + accountId: String, + conversationId: String, + messageId: String, + contactId: String, + status: Int, + ) { val account = getAccount(accountId) ?: return + val interactionStatus = InteractionStatus.fromIntTextMessage(status) + val messageState = Interaction.MessageStates.fromInt(status) + if (conversationId.isEmpty() && !account.isJami) { mHistoryService - .accountMessageStatusChanged(accountId, messageId, contactId, newStatus) - .subscribe({ t: TextMessage -> messageSubject.onNext(t) }) { e: Throwable -> - Log.e(TAG, "Error updating message: " + e.localizedMessage) } + .accountMessageStatusChanged( + accountId, messageId, contactId, interactionStatus, messageState + ) + .blockingSubscribe({ t: TextMessage -> messageSubject.onNext(t) }) + { e: Throwable -> Log.e(TAG, "Error updating message: " + e.localizedMessage) } } else { account.getSwarm(conversationId) - ?.updateSwarmInteraction(messageId, Uri.fromId(contactId), newStatus) + ?.updateSwarmInteraction(messageId, Uri.fromId(contactId), messageState) } } @@ -1280,8 +1289,11 @@ class AccountService( val interaction = getInteraction(account, conversation, body) val edits = message.editions.map { getInteraction(account, conversation, it.toNative()) } val reactions = message.reactions.map { getInteraction(account, conversation, it.toNative()) } + val statusMap = message.status.mapValues { Interaction.MessageStates.fromInt(it.value) } + interaction.addEdits(edits) interaction.addReactions(reactions) + interaction.statusMap = statusMap return interaction } diff --git a/jami-android/libjamiclient/src/main/kotlin/net/jami/services/HistoryService.kt b/jami-android/libjamiclient/src/main/kotlin/net/jami/services/HistoryService.kt index c0db7d2af9bc5647b11ca5dba92ffe386923cb74..cb6ae53289fb30cb4aceb33273f3063bff1db819 100644 --- a/jami-android/libjamiclient/src/main/kotlin/net/jami/services/HistoryService.kt +++ b/jami-android/libjamiclient/src/main/kotlin/net/jami/services/HistoryService.kt @@ -179,7 +179,13 @@ abstract class HistoryService { txt }.subscribeOn(scheduler) - fun accountMessageStatusChanged(accountId: String, daemonId: String, peer: String, status: InteractionStatus): Single<TextMessage> = Single.fromCallable { + fun accountMessageStatusChanged( + accountId: String, + daemonId: String, + peer: String, + interactionStatus: InteractionStatus, + messageState: Interaction.MessageStates, + ): Single<TextMessage> = Single.fromCallable { val textList = getInteractionDataDao(accountId).queryForEq(Interaction.COLUMN_DAEMON_ID, daemonId) if (textList == null || textList.isEmpty()) { throw RuntimeException("accountMessageStatusChanged: not able to find message with id $daemonId in database") @@ -190,7 +196,8 @@ abstract class HistoryService { throw RuntimeException("accountMessageStatusChanged: received an invalid text message") } val msg = TextMessage(text) - msg.status = status + msg.status = interactionStatus + msg.statusMap = msg.statusMap.plus(accountId to messageState) getInteractionDataDao(accountId).update(msg) msg.account = accountId msg