diff --git a/app/src/main/java/org/linphone/ui/main/chat/adapter/ConversationEventAdapter.kt b/app/src/main/java/org/linphone/ui/main/chat/adapter/ConversationEventAdapter.kt index 94dbf52f3..b4bd4e32d 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/adapter/ConversationEventAdapter.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/adapter/ConversationEventAdapter.kt @@ -28,7 +28,6 @@ import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import org.linphone.R -import org.linphone.core.ChatMessage import org.linphone.databinding.ChatBubbleIncomingBinding import org.linphone.databinding.ChatBubbleOutgoingBinding import org.linphone.databinding.ChatEventBinding @@ -44,8 +43,6 @@ class ConversationEventAdapter( const val INCOMING_CHAT_MESSAGE = 1 const val OUTGOING_CHAT_MESSAGE = 2 const val EVENT = 3 - - const val MAX_TIME_TO_GROUP_MESSAGES = 60 // 1 minute } val chatMessageLongPressEvent = MutableLiveData>>() @@ -107,42 +104,6 @@ class ConversationEventAdapter( } } - fun groupPreviousItem(item: ChatMessageModel, position: Int): Boolean { - return if (position == 0) { - false - } else { - val previous = position - 1 - if (getItemViewType(position) == getItemViewType(previous)) { - val previousItem = getItem(previous).model as ChatMessageModel - if (kotlin.math.abs(item.timestamp - previousItem.timestamp) < MAX_TIME_TO_GROUP_MESSAGES) { - previousItem.fromSipUri == item.fromSipUri - } else { - false - } - } else { - false - } - } - } - - fun isLastItemOfGroup(item: ChatMessageModel, position: Int): Boolean { - return if (position == itemCount - 1) { - true - } else { - val next = position + 1 - if (getItemViewType(next) == getItemViewType(position)) { - val nextItem = getItem(next).model as ChatMessageModel - if (kotlin.math.abs(item.timestamp - nextItem.timestamp) < MAX_TIME_TO_GROUP_MESSAGES) { - nextItem.fromSipUri != item.fromSipUri - } else { - true - } - } else { - true - } - } - } - inner class IncomingBubbleViewHolder( val binding: ChatBubbleIncomingBinding ) : RecyclerView.ViewHolder(binding.root) { @@ -150,10 +111,6 @@ class ConversationEventAdapter( with(binding) { model = message - val position = bindingAdapterPosition - isGroupedWithPreviousOne = groupPreviousItem(message, position) - isLastOneOfGroup = isLastItemOfGroup(message, position) - setOnLongClickListener { val screen = IntArray(2) root.getLocationOnScreen(screen) @@ -175,10 +132,6 @@ class ConversationEventAdapter( with(binding) { model = message - val position = bindingAdapterPosition - isGroupedWithPreviousOne = groupPreviousItem(message, position) - isLastOneOfGroup = isLastItemOfGroup(message, position) - lifecycleOwner = viewLifecycleOwner executePendingBindings() } @@ -214,8 +167,9 @@ class ConversationEventAdapter( return if (oldItem.isEvent && newItem.isEvent) { true } else { - val newData = (newItem.model as ChatMessageModel) - newData.state.value == ChatMessage.State.Displayed + val oldModel = (newItem.model as ChatMessageModel) + val newModel = newItem.model + oldModel.statusIcon.value == newModel.statusIcon.value } } } diff --git a/app/src/main/java/org/linphone/ui/main/chat/model/ChatMessageModel.kt b/app/src/main/java/org/linphone/ui/main/chat/model/ChatMessageModel.kt index 1e7291969..08a038887 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/model/ChatMessageModel.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/model/ChatMessageModel.kt @@ -23,6 +23,7 @@ import androidx.annotation.UiThread import androidx.annotation.WorkerThread import androidx.lifecycle.MutableLiveData import org.linphone.LinphoneApplication.Companion.coreContext +import org.linphone.R import org.linphone.core.Address import org.linphone.core.ChatMessage import org.linphone.core.ChatMessageListenerStub @@ -35,7 +36,10 @@ import org.linphone.utils.TimestampUtils class ChatMessageModel @WorkerThread constructor( val chatMessage: ChatMessage, - val avatarModel: ContactAvatarModel + val avatarModel: ContactAvatarModel, + val isFromGroup: Boolean, + val isGroupedWithPreviousOne: Boolean, + val isGroupedWithNextOne: Boolean ) { companion object { private const val TAG = "[Chat Message Model]" @@ -45,15 +49,11 @@ class ChatMessageModel @WorkerThread constructor( val isOutgoing = chatMessage.isOutgoing - val state = MutableLiveData() + val statusIcon = MutableLiveData() val text = LinphoneUtils.getTextDescribingMessage(chatMessage) - val fromSipUri = chatMessage.fromAddress.asStringUriOnly() - - val timestamp = chatMessage.time - - val time = TimestampUtils.toString(timestamp) + val time = TimestampUtils.toString(chatMessage.time) val dismissLongPressMenuEvent: MutableLiveData> by lazy { MutableLiveData>() @@ -62,7 +62,7 @@ class ChatMessageModel @WorkerThread constructor( private val chatMessageListener = object : ChatMessageListenerStub() { @WorkerThread override fun onMsgStateChanged(message: ChatMessage, messageState: ChatMessage.State?) { - state.postValue(chatMessage.state) + computeStatusIcon(chatMessage.state) } @WorkerThread @@ -80,7 +80,7 @@ class ChatMessageModel @WorkerThread constructor( init { chatMessage.addListener(chatMessageListener) - state.postValue(chatMessage.state) + computeStatusIcon(chatMessage.state) } @WorkerThread @@ -101,4 +101,26 @@ class ChatMessageModel @WorkerThread constructor( @UiThread fun showDeliveryInfo() { } + + @WorkerThread + private fun computeStatusIcon(state: ChatMessage.State) { + val icon = when (state) { + ChatMessage.State.Displayed -> { + R.drawable.checks + } + ChatMessage.State.DeliveredToUser -> { + R.drawable.check + } + ChatMessage.State.Delivered -> { + R.drawable.envelope_simple + } + ChatMessage.State.NotDelivered -> { + R.drawable.warning_circle + } + else -> { + R.drawable.in_progress + } + } + statusIcon.postValue(icon) + } } diff --git a/app/src/main/java/org/linphone/ui/main/chat/model/EventLogModel.kt b/app/src/main/java/org/linphone/ui/main/chat/model/EventLogModel.kt index 659f6a97a..c17d49ddc 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/model/EventLogModel.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/model/EventLogModel.kt @@ -23,7 +23,13 @@ import androidx.annotation.WorkerThread import org.linphone.core.EventLog import org.linphone.ui.main.contacts.model.ContactAvatarModel -class EventLogModel @WorkerThread constructor(eventLog: EventLog, avatarModel: ContactAvatarModel) { +class EventLogModel @WorkerThread constructor( + val eventLog: EventLog, + avatarModel: ContactAvatarModel, + isFromGroup: Boolean, + isGroupedWithPreviousOne: Boolean, + isGroupedWithNextOne: Boolean +) { val type: EventLog.Type = eventLog.type val isEvent = type != EventLog.Type.ConferenceChatMessage @@ -31,7 +37,13 @@ class EventLogModel @WorkerThread constructor(eventLog: EventLog, avatarModel: C val model = if (isEvent) { EventModel(eventLog) } else { - ChatMessageModel(eventLog.chatMessage!!, avatarModel) + ChatMessageModel( + eventLog.chatMessage!!, + avatarModel, + isFromGroup, + isGroupedWithPreviousOne, + isGroupedWithNextOne + ) } val notifyId = eventLog.notifyId diff --git a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationViewModel.kt b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationViewModel.kt index 61e6abacc..7c04d3575 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationViewModel.kt @@ -43,6 +43,8 @@ import org.linphone.utils.LinphoneUtils class ConversationViewModel @UiThread constructor() : ViewModel() { companion object { private const val TAG = "[Conversation ViewModel]" + + const val MAX_TIME_TO_GROUP_MESSAGES = 60 // 1 minute } val showBackButton = MutableLiveData() @@ -79,7 +81,13 @@ class ConversationViewModel @UiThread constructor() : ViewModel() { list.addAll(events.value.orEmpty()) val avatarModel = getAvatarModelForAddress(message?.localAddress) - list.add(EventLogModel(eventLog, avatarModel)) + val lastEvent = events.value.orEmpty().lastOrNull() + val group = if (lastEvent != null) { + shouldWeGroupTwoEvents(eventLog, lastEvent.eventLog) + } else { + false + } + list.add(EventLogModel(eventLog, avatarModel, isGroup.value == true, group, true)) events.postValue(list) } @@ -103,7 +111,7 @@ class ConversationViewModel @UiThread constructor() : ViewModel() { } @WorkerThread - override fun onChatMessagesReceived(chatRoom: ChatRoom, eventLogs: Array) { + override fun onChatMessagesReceived(chatRoom: ChatRoom, eventLogs: Array) { Log.i("$TAG Received [${eventLogs.size}] new message(s)") chatRoom.markAsRead() computeComposingLabel() @@ -111,15 +119,13 @@ class ConversationViewModel @UiThread constructor() : ViewModel() { val list = arrayListOf() list.addAll(events.value.orEmpty()) - for (eventLog in eventLogs) { - val address = if (eventLog.type == EventLog.Type.ConferenceChatMessage) { - eventLog.chatMessage?.fromAddress - } else { - eventLog.participantAddress - } - val avatarModel = getAvatarModelForAddress(address) - list.add(EventLogModel(eventLog, avatarModel)) - } + val newList = getEventsListFromHistory( + eventLogs, + isGroupChatRoom = isGroup.value == true + ) + list.addAll(newList) + + // TODO: handle case when first one of the newly received messages should be grouped with last one of the current list events.postValue(list) } @@ -211,11 +217,9 @@ class ConversationViewModel @UiThread constructor() : ViewModel() { private fun configureChatRoom() { computeComposingLabel() - isGroup.postValue( - !chatRoom.hasCapability(ChatRoom.Capabilities.OneToOne.toInt()) && chatRoom.hasCapability( - ChatRoom.Capabilities.Conference.toInt() - ) - ) + val isGroupChatRoom = !chatRoom.hasCapability(ChatRoom.Capabilities.OneToOne.toInt()) && + chatRoom.hasCapability(ChatRoom.Capabilities.Conference.toInt()) + isGroup.postValue(isGroupChatRoom) val empty = chatRoom.hasCapability(ChatRoom.Capabilities.Conference.toInt()) && chatRoom.participants.isEmpty() val readOnly = chatRoom.isReadOnly || empty @@ -245,19 +249,66 @@ class ConversationViewModel @UiThread constructor() : ViewModel() { val groupAvatar = GroupAvatarModel(friends) groupAvatarModel.postValue(groupAvatar) - val eventsList = arrayListOf() - val history = chatRoom.getHistoryEvents(0) - for (event in history) { - val avatar = getAvatarModelForAddress(event.chatMessage?.fromAddress) - val model = EventLogModel(event, avatar) - eventsList.add(model) - } + val eventsList = getEventsListFromHistory(history, isGroupChatRoom) events.postValue(eventsList) chatRoom.markAsRead() } + @WorkerThread + private fun getEventsListFromHistory(history: Array, isGroupChatRoom: Boolean): ArrayList { + val eventsList = arrayListOf() + val groupedEventLogs = arrayListOf() + for (event in history) { + if (groupedEventLogs.isEmpty()) { + groupedEventLogs.add(event) + continue + } + + val previousGroupEvent = groupedEventLogs.last() + val groupEvents = shouldWeGroupTwoEvents(event, previousGroupEvent) + + if (!groupEvents) { + // Handle all events in group, then re-start a new group with current item + var index = 0 + for (groupedEvent in groupedEventLogs) { + val avatar = getAvatarModelForAddress(groupedEvent.chatMessage?.fromAddress) + val model = EventLogModel( + groupedEvent, + avatar, + isGroupChatRoom, + index > 0, + index == groupedEventLogs.size - 1 + ) + eventsList.add(model) + + index += 1 + } + + groupedEventLogs.clear() + } + + groupedEventLogs.add(event) + } + return eventsList + } + + @WorkerThread + private fun shouldWeGroupTwoEvents(event: EventLog, previousGroupEvent: EventLog): Boolean { + return if (previousGroupEvent.type == EventLog.Type.ConferenceChatMessage && event.type == EventLog.Type.ConferenceChatMessage) { + val previousChatMessage = previousGroupEvent.chatMessage!! + val chatMessage = event.chatMessage!! + + // If they have the same direction, the same from address and were sent in a short timelapse, group them + chatMessage.isOutgoing == previousChatMessage.isOutgoing && + chatMessage.fromAddress.weakEqual(previousChatMessage.fromAddress) && + kotlin.math.abs(chatMessage.time - previousChatMessage.time) < MAX_TIME_TO_GROUP_MESSAGES + } else { + false + } + } + @WorkerThread private fun getAvatarModelForAddress(address: Address?): ContactAvatarModel { Log.i("Looking for avatar model with address [${address?.asStringUriOnly()}]") diff --git a/app/src/main/res/layout/chat_bubble_incoming.xml b/app/src/main/res/layout/chat_bubble_incoming.xml index b7b2e16c7..a7f22e184 100644 --- a/app/src/main/res/layout/chat_bubble_incoming.xml +++ b/app/src/main/res/layout/chat_bubble_incoming.xml @@ -13,19 +13,13 @@ - - @@ -63,7 +57,7 @@ android:layout_width="0dp" android:layout_height="0dp" android:layout_marginStart="10dp" - android:src="@{isGroupedWithPreviousOne ? @drawable/shape_chat_bubble_incoming_full : @drawable/shape_chat_bubble_incoming_first, default=@drawable/shape_chat_bubble_incoming_first}" + android:src="@{model.isGroupedWithPreviousOne ? @drawable/shape_chat_bubble_incoming_full : @drawable/shape_chat_bubble_incoming_first, default=@drawable/shape_chat_bubble_incoming_first}" app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toBottomOf="@id/bubble_bottom_barrier" app:layout_constraintStart_toEndOf="@id/avatar" @@ -127,7 +121,7 @@ android:layout_marginBottom="5dp" android:text="@{model.time, default=`13:40`}" android:textSize="12sp" - android:visibility="@{isLastOneOfGroup ? View.VISIBLE : View.GONE}" + android:visibility="@{model.isGroupedWithNextOne ? View.VISIBLE : View.GONE}" app:layout_constraintTop_toBottomOf="@id/text_message" app:layout_constraintStart_toStartOf="@id/text_message" app:layout_constraintBottom_toBottomOf="@id/background"/> @@ -140,8 +134,8 @@ android:layout_height="@dimen/small_icon_size" android:layout_marginStart="5dp" android:layout_marginTop="2dp" - android:src="@{model.state == State.Displayed ? @drawable/checks : model.state == State.DeliveredToUser ? @drawable/check : model.state == State.Delivered ? @drawable/envelope_simple : model.state == State.NotDelivered ? @drawable/warning_circle : @drawable/in_progress, default=@drawable/in_progress}" - android:visibility="@{isLastOneOfGroup ? View.VISIBLE : View.GONE}" + android:src="@{model.statusIcon, default=@drawable/checks}" + android:visibility="@{model.isGroupedWithNextOne ? View.VISIBLE : View.GONE}" app:layout_constraintTop_toTopOf="@id/date_time" app:layout_constraintStart_toEndOf="@id/date_time" app:layout_constraintBottom_toBottomOf="@id/date_time" diff --git a/app/src/main/res/layout/chat_bubble_outgoing.xml b/app/src/main/res/layout/chat_bubble_outgoing.xml index b14b2a245..5d615b685 100644 --- a/app/src/main/res/layout/chat_bubble_outgoing.xml +++ b/app/src/main/res/layout/chat_bubble_outgoing.xml @@ -12,26 +12,20 @@ - - @@ -102,8 +96,8 @@ android:layout_height="@dimen/small_icon_size" android:layout_marginEnd="18dp" android:layout_marginTop="2dp" - android:src="@{model.state == State.Displayed ? @drawable/checks : model.state == State.DeliveredToUser ? @drawable/check : model.state == State.Delivered ? @drawable/envelope_simple : model.state == State.NotDelivered ? @drawable/warning_circle : @drawable/in_progress, default=@drawable/in_progress}" - android:visibility="@{isLastOneOfGroup ? View.VISIBLE : View.GONE}" + android:src="@{model.statusIcon, default=@drawable/checks}" + android:visibility="@{model.isGroupedWithNextOne ? View.VISIBLE : View.GONE}" app:layout_constraintTop_toTopOf="@id/date_time" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintBottom_toBottomOf="@id/date_time" diff --git a/app/src/main/res/layout/chat_conversation_long_press_menu.xml b/app/src/main/res/layout/chat_conversation_long_press_menu.xml index 5e81b27c9..5b2a1fa59 100644 --- a/app/src/main/res/layout/chat_conversation_long_press_menu.xml +++ b/app/src/main/res/layout/chat_conversation_long_press_menu.xml @@ -133,8 +133,6 @@ app:layout_constraintTop_toBottomOf="@id/emojis_background" app:layout_constraintBottom_toTopOf="@id/reply" model="@{model}" - isGroupedWithPreviousOne="@{false}" - isLastOneOfGroup="@{true}" layout="@layout/chat_bubble_incoming"/>