Reworked algorithm that groups chat messages together

This commit is contained in:
Sylvain Berfini 2023-10-12 16:29:30 +02:00
parent c9db3df251
commit 888c8c453a
7 changed files with 134 additions and 109 deletions

View file

@ -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<Event<Pair<ChatMessageModel, Int>>>()
@ -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
}
}
}

View file

@ -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<ChatMessage.State>()
val statusIcon = MutableLiveData<Int>()
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<Event<Boolean>> by lazy {
MutableLiveData<Event<Boolean>>()
@ -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)
}
}

View file

@ -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

View file

@ -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<Boolean>()
@ -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<out EventLog>) {
override fun onChatMessagesReceived(chatRoom: ChatRoom, eventLogs: Array<EventLog>) {
Log.i("$TAG Received [${eventLogs.size}] new message(s)")
chatRoom.markAsRead()
computeComposingLabel()
@ -111,15 +119,13 @@ class ConversationViewModel @UiThread constructor() : ViewModel() {
val list = arrayListOf<EventLogModel>()
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<EventLogModel>()
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<EventLog>, isGroupChatRoom: Boolean): ArrayList<EventLogModel> {
val eventsList = arrayListOf<EventLogModel>()
val groupedEventLogs = arrayListOf<EventLog>()
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()}]")

View file

@ -13,19 +13,13 @@
<variable
name="model"
type="org.linphone.ui.main.chat.model.ChatMessageModel" />
<variable
name="isGroupedWithPreviousOne"
type="Boolean" />
<variable
name="isLastOneOfGroup"
type="Boolean" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:onLongClick="@{onLongClickListener}"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@{isGroupedWithPreviousOne ? @dimen/chat_bubble_grouped_top_margin : @dimen/chat_bubble_top_margin, default=@dimen/chat_bubble_top_margin}"
android:layout_marginTop="@{model.isGroupedWithPreviousOne ? @dimen/chat_bubble_grouped_top_margin : @dimen/chat_bubble_top_margin, default=@dimen/chat_bubble_top_margin}"
android:layout_marginStart="16dp">
<io.getstream.avatarview.AvatarView
@ -34,7 +28,7 @@
android:layout_height="@dimen/avatar_bubble_size"
android:adjustViewBounds="true"
android:background="@drawable/shape_circle_light_blue_background"
android:visibility="@{isGroupedWithPreviousOne ? View.INVISIBLE : View.VISIBLE}"
android:visibility="@{!model.isFromGroup ? View.GONE: model.isGroupedWithPreviousOne ? View.INVISIBLE : View.VISIBLE}"
contactAvatar="@{model.avatarModel}"
app:avatarViewPlaceholder="@drawable/user_circle"
app:avatarViewInitialsBackgroundColor="@color/gray_main2_200"
@ -54,7 +48,7 @@
android:background="@drawable/led_background"
android:padding="1dp"
app:presenceIcon="@{model.avatarModel.presenceStatus}"
android:visibility="@{isGroupedWithPreviousOne || model.avatarModel.presenceStatus == ConsolidatedPresence.Offline ? View.GONE : View.VISIBLE}"
android:visibility="@{model.isGroupedWithPreviousOne || !model.isFromGroup || model.avatarModel.presenceStatus == ConsolidatedPresence.Offline ? View.GONE : View.VISIBLE}"
app:layout_constraintEnd_toEndOf="@id/avatar"
app:layout_constraintBottom_toBottomOf="@id/avatar"/>
@ -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"

View file

@ -12,26 +12,20 @@
<variable
name="model"
type="org.linphone.ui.main.chat.model.ChatMessageModel" />
<variable
name="isGroupedWithPreviousOne"
type="Boolean" />
<variable
name="isLastOneOfGroup"
type="Boolean" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:onLongClick="@{onLongClickListener}"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@{isGroupedWithPreviousOne ? @dimen/chat_bubble_grouped_top_margin : @dimen/chat_bubble_top_margin, default=@dimen/chat_bubble_top_margin}"
android:layout_marginTop="@{model.isGroupedWithPreviousOne ? @dimen/chat_bubble_grouped_top_margin : @dimen/chat_bubble_top_margin, default=@dimen/chat_bubble_top_margin}"
android:layout_marginEnd="16dp">
<ImageView
android:id="@+id/background"
android:layout_width="0dp"
android:layout_height="0dp"
android:src="@{isGroupedWithPreviousOne ? @drawable/shape_chat_bubble_outgoing_full : @drawable/shape_chat_bubble_outgoing_first, default=@drawable/shape_chat_bubble_outgoing_first}"
android:src="@{model.isGroupedWithPreviousOne ? @drawable/shape_chat_bubble_outgoing_full : @drawable/shape_chat_bubble_outgoing_first, default=@drawable/shape_chat_bubble_outgoing_first}"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="@id/bubble_bottom_barrier"
app:layout_constraintStart_toStartOf="@id/bubble_start_barrier"
@ -89,7 +83,7 @@
android:layout_marginEnd="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_constraintEnd_toStartOf="@id/delivery_status"
app:layout_constraintBottom_toBottomOf="@id/background"/>
@ -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"

View file

@ -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"/>
<View