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 new file mode 100644 index 000000000..aae82319e --- /dev/null +++ b/app/src/main/java/org/linphone/ui/main/chat/adapter/ConversationEventAdapter.kt @@ -0,0 +1,184 @@ +/* + * Copyright (c) 2010-2023 Belledonne Communications SARL. + * + * This file is part of linphone-android + * (see https://www.linphone.org). + * + * 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 . + */ +package org.linphone.ui.main.chat.adapter + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.databinding.DataBindingUtil +import androidx.lifecycle.LifecycleOwner +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 +import org.linphone.ui.main.chat.model.ChatMessageModel +import org.linphone.ui.main.chat.model.EventLogModel +import org.linphone.ui.main.chat.model.EventModel + +class ConversationEventAdapter( + private val viewLifecycleOwner: LifecycleOwner +) : ListAdapter(EventLogDiffCallback()) { + companion object { + const val INCOMING_CHAT_MESSAGE = 1 + const val OUTGOING_CHAT_MESSAGE = 2 + const val EVENT = 3 + } + + var selectedAdapterPosition = -1 + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return when (viewType) { + INCOMING_CHAT_MESSAGE -> createIncomingChatBubble(parent) + OUTGOING_CHAT_MESSAGE -> createOutgoingChatBubble(parent) + else -> createEvent(parent) + } + } + + override fun getItemViewType(position: Int): Int { + val data = getItem(position) + if (data.data is ChatMessageModel) { + if (data.data.isOutgoing) { + return OUTGOING_CHAT_MESSAGE + } + return INCOMING_CHAT_MESSAGE + } + return EVENT + } + + private fun createIncomingChatBubble(parent: ViewGroup): IncomingBubbleViewHolder { + val binding: ChatBubbleIncomingBinding = DataBindingUtil.inflate( + LayoutInflater.from(parent.context), + R.layout.chat_bubble_incoming, + parent, + false + ) + return IncomingBubbleViewHolder(binding) + } + + private fun createOutgoingChatBubble(parent: ViewGroup): OutgoingBubbleViewHolder { + val binding: ChatBubbleOutgoingBinding = DataBindingUtil.inflate( + LayoutInflater.from(parent.context), + R.layout.chat_bubble_outgoing, + parent, + false + ) + return OutgoingBubbleViewHolder(binding) + } + + private fun createEvent(parent: ViewGroup): EventViewHolder { + val binding: ChatEventBinding = DataBindingUtil.inflate( + LayoutInflater.from(parent.context), + R.layout.chat_event, + parent, + false + ) + return EventViewHolder(binding) + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + val eventLog = getItem(position) + when (holder) { + is IncomingBubbleViewHolder -> holder.bind(eventLog.data as ChatMessageModel) + is OutgoingBubbleViewHolder -> holder.bind(eventLog.data as ChatMessageModel) + is EventViewHolder -> holder.bind(eventLog.data as EventModel) + } + } + + fun resetSelection() { + notifyItemChanged(selectedAdapterPosition) + selectedAdapterPosition = -1 + } + + inner class IncomingBubbleViewHolder( + val binding: ChatBubbleIncomingBinding + ) : RecyclerView.ViewHolder(binding.root) { + fun bind(chatMessageData: ChatMessageModel) { + with(binding) { + model = chatMessageData + + binding.setOnLongClickListener { + selectedAdapterPosition = bindingAdapterPosition + binding.root.isSelected = true + true + } + + lifecycleOwner = viewLifecycleOwner + executePendingBindings() + } + } + } + + inner class OutgoingBubbleViewHolder( + val binding: ChatBubbleOutgoingBinding + ) : RecyclerView.ViewHolder(binding.root) { + fun bind(chatMessageData: ChatMessageModel) { + with(binding) { + model = chatMessageData + + binding.setOnLongClickListener { + selectedAdapterPosition = bindingAdapterPosition + binding.root.isSelected = true + true + } + + lifecycleOwner = viewLifecycleOwner + executePendingBindings() + } + } + } + inner class EventViewHolder( + val binding: ChatEventBinding + ) : RecyclerView.ViewHolder(binding.root) { + fun bind(eventData: EventModel) { + with(binding) { + model = eventData + + lifecycleOwner = viewLifecycleOwner + executePendingBindings() + } + } + } + + private class EventLogDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: EventLogModel, newItem: EventLogModel): Boolean { + return if (oldItem.isEvent && newItem.isEvent) { + oldItem.notifyId == newItem.notifyId + } else if (!oldItem.isEvent && !newItem.isEvent) { + val oldData = (oldItem.data as ChatMessageModel) + val newData = (newItem.data as ChatMessageModel) + oldData.id.isNotEmpty() && oldData.id == newData.id + } else { + false + } + } + + override fun areContentsTheSame(oldItem: EventLogModel, newItem: EventLogModel): Boolean { + return if (oldItem.isEvent && newItem.isEvent) { + true + } else { + val newData = (newItem.data as ChatMessageModel) + newData.state.value == ChatMessage.State.Displayed + } + } + } +} diff --git a/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationFragment.kt b/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationFragment.kt index 8a5f6c9a8..2f48bfae0 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationFragment.kt @@ -23,11 +23,15 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.view.doOnPreDraw import androidx.lifecycle.ViewModelProvider import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.bottomsheet.BottomSheetBehavior import org.linphone.core.tools.Log import org.linphone.databinding.ChatConversationFragmentBinding +import org.linphone.ui.main.chat.adapter.ConversationEventAdapter import org.linphone.ui.main.chat.viewmodel.ConversationViewModel import org.linphone.ui.main.fragment.GenericFragment import org.linphone.utils.Event @@ -43,6 +47,8 @@ class ConversationFragment : GenericFragment() { private val args: ConversationFragmentArgs by navArgs() + private lateinit var adapter: ConversationEventAdapter + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -67,6 +73,14 @@ class ConversationFragment : GenericFragment() { viewModel = ViewModelProvider(this)[ConversationViewModel::class.java] binding.viewModel = viewModel + binding.setBackClickListener { + goBack() + } + + sharedViewModel.isSlidingPaneSlideable.observe(viewLifecycleOwner) { slideable -> + viewModel.showBackButton.value = slideable + } + val localSipUri = args.localSipUri val remoteSipUri = args.remoteSipUri Log.i( @@ -74,27 +88,57 @@ class ConversationFragment : GenericFragment() { ) viewModel.findChatRoom(localSipUri, remoteSipUri) - binding.setBackClickListener { - goBack() - } - viewModel.chatRoomFoundEvent.observe(viewLifecycleOwner) { it.consume { found -> if (found) { Log.i( "$TAG Found matching chat room for local SIP URI [$localSipUri] and remote SIP URI [$remoteSipUri]" ) - startPostponedEnterTransition() - sharedViewModel.openSlidingPaneEvent.value = Event(true) } else { - Log.e("$TAG Failed to find chat room, going back") - goBack() + (view.parent as? ViewGroup)?.doOnPreDraw { + Log.e("$TAG Failed to find chat room, going back") + goBack() + } } } } - sharedViewModel.isSlidingPaneSlideable.observe(viewLifecycleOwner) { slideable -> - viewModel.showBackButton.value = slideable + adapter = ConversationEventAdapter(viewLifecycleOwner) + binding.eventsList.setHasFixedSize(false) + binding.eventsList.adapter = adapter + + val layoutManager = LinearLayoutManager(requireContext()) + binding.eventsList.layoutManager = layoutManager + + viewModel.events.observe(viewLifecycleOwner) { + val currentCount = adapter.itemCount + adapter.submitList(it) + Log.i("$TAG Events (messages) list updated with [${it.size}] items") + + if (currentCount < it.size) { + binding.eventsList.scrollToPosition(it.size - 1) + } + + (view.parent as? ViewGroup)?.doOnPreDraw { + startPostponedEnterTransition() + sharedViewModel.openSlidingPaneEvent.value = Event(true) + } + } + + val emojisBottomSheetBehavior = BottomSheetBehavior.from(binding.emojiPicker) + emojisBottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED + emojisBottomSheetBehavior.isDraggable = false // To allow scrolling through the emojis + + binding.setOpenEmojiPickerClickListener { + /*val state = emojisBottomSheetBehavior.state + if (state == BottomSheetBehavior.STATE_COLLAPSED) { + emojisBottomSheetBehavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED + if (binding.emojiPicker.visibility == View.GONE) { + binding.emojiPicker.visibility = View.VISIBLE + } + } else { + emojisBottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED + }*/ } } } 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 new file mode 100644 index 000000000..2135c2fc9 --- /dev/null +++ b/app/src/main/java/org/linphone/ui/main/chat/model/ChatMessageModel.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2010-2023 Belledonne Communications SARL. + * + * This file is part of linphone-android + * (see https://www.linphone.org). + * + * 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 . + */ +package org.linphone.ui.main.chat.model + +import androidx.annotation.WorkerThread +import androidx.lifecycle.MutableLiveData +import org.linphone.core.ChatMessage +import org.linphone.ui.main.contacts.model.ContactAvatarModel +import org.linphone.utils.LinphoneUtils + +class ChatMessageModel @WorkerThread constructor( + chatMessage: ChatMessage, + val avatarModel: ContactAvatarModel +) { + val id = chatMessage.messageId + + val isOutgoing = chatMessage.isOutgoing + + val state = MutableLiveData() + + val text = MutableLiveData() + + init { + state.postValue(chatMessage.state) + text.postValue(LinphoneUtils.getTextDescribingMessage(chatMessage)) + } +} 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 new file mode 100644 index 000000000..120058f05 --- /dev/null +++ b/app/src/main/java/org/linphone/ui/main/chat/model/EventLogModel.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2010-2023 Belledonne Communications SARL. + * + * This file is part of linphone-android + * (see https://www.linphone.org). + * + * 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 . + */ +package org.linphone.ui.main.chat.model + +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) { + val type: EventLog.Type = eventLog.type + + val isEvent = type != EventLog.Type.ConferenceChatMessage + + val data = if (isEvent) { + EventModel(eventLog) + } else { + ChatMessageModel(eventLog.chatMessage!!, avatarModel) + } + + val notifyId = eventLog.notifyId + + fun destroy() { + /*when (data) { + is EventData -> data.destroy() + is ChatMessageModel -> data.destroy() + }*/ + } +} diff --git a/app/src/main/java/org/linphone/ui/main/chat/model/EventModel.kt b/app/src/main/java/org/linphone/ui/main/chat/model/EventModel.kt new file mode 100644 index 000000000..d24131afd --- /dev/null +++ b/app/src/main/java/org/linphone/ui/main/chat/model/EventModel.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2010-2023 Belledonne Communications SARL. + * + * This file is part of linphone-android + * (see https://www.linphone.org). + * + * 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 . + */ +package org.linphone.ui.main.chat.model + +import androidx.annotation.WorkerThread +import org.linphone.core.EventLog + +class EventModel @WorkerThread constructor(eventLog: EventLog) 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 953539711..2de78e2ae 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 @@ -27,6 +27,7 @@ import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.core.ChatRoom import org.linphone.core.Factory import org.linphone.core.tools.Log +import org.linphone.ui.main.chat.model.EventLogModel import org.linphone.ui.main.contacts.model.ContactAvatarModel import org.linphone.utils.Event @@ -39,6 +40,8 @@ class ConversationViewModel @UiThread constructor() : ViewModel() { val avatarModel = MutableLiveData() + val events = MutableLiveData>() + val chatRoomFoundEvent = MutableLiveData>() private lateinit var chatRoom: ChatRoom @@ -86,12 +89,21 @@ class ConversationViewModel @UiThread constructor() : ViewModel() { } val friend = coreContext.contactsManager.findContactByAddress(address) - if (friend != null) { - avatarModel.postValue(ContactAvatarModel(friend)) + val avatar = if (friend != null) { + ContactAvatarModel(friend) } else { val fakeFriend = coreContext.core.createFriend() fakeFriend.address = address - avatarModel.postValue(ContactAvatarModel(fakeFriend)) + ContactAvatarModel(fakeFriend) } + avatarModel.postValue(avatar) + + val eventsList = arrayListOf() + val history = chatRoom.getHistoryEvents(0) + for (event in history) { + val model = EventLogModel(event, avatar) + eventsList.add(model) + } + events.postValue(eventsList) } } diff --git a/app/src/main/res/drawable/shape_chat_bubble_incoming_first.xml b/app/src/main/res/drawable/shape_chat_bubble_incoming_first.xml new file mode 100644 index 000000000..e82f7df3c --- /dev/null +++ b/app/src/main/res/drawable/shape_chat_bubble_incoming_first.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_chat_bubble_incoming_full.xml b/app/src/main/res/drawable/shape_chat_bubble_incoming_full.xml new file mode 100644 index 000000000..df3a98fea --- /dev/null +++ b/app/src/main/res/drawable/shape_chat_bubble_incoming_full.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_chat_bubble_outgoing_first.xml b/app/src/main/res/drawable/shape_chat_bubble_outgoing_first.xml new file mode 100644 index 000000000..1d29b6a85 --- /dev/null +++ b/app/src/main/res/drawable/shape_chat_bubble_outgoing_first.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_chat_bubble_outgoing_full.xml b/app/src/main/res/drawable/shape_chat_bubble_outgoing_full.xml new file mode 100644 index 000000000..6ea2e234d --- /dev/null +++ b/app/src/main/res/drawable/shape_chat_bubble_outgoing_full.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/chat_bubble_incoming.xml b/app/src/main/res/layout/chat_bubble_incoming.xml new file mode 100644 index 000000000..7d7bf2e03 --- /dev/null +++ b/app/src/main/res/layout/chat_bubble_incoming.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/chat_bubble_outgoing.xml b/app/src/main/res/layout/chat_bubble_outgoing.xml new file mode 100644 index 000000000..0144dfe78 --- /dev/null +++ b/app/src/main/res/layout/chat_bubble_outgoing.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/chat_conversation_fragment.xml b/app/src/main/res/layout/chat_conversation_fragment.xml index 711a4ae43..83b25ce01 100644 --- a/app/src/main/res/layout/chat_conversation_fragment.xml +++ b/app/src/main/res/layout/chat_conversation_fragment.xml @@ -10,219 +10,152 @@ name="backClickListener" type="View.OnClickListener" /> - - - + android:layout_height="match_parent"> - - - - - - - - - - - - - - - + android:layout_height="match_parent" + android:layout_marginBottom="80dp" + android:background="@color/white"> - + - + - + + + + + + + + + + + + + + + + - - - - - - - - + app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior"/> - + - + \ No newline at end of file diff --git a/app/src/main/res/layout/chat_conversation_send_area.xml b/app/src/main/res/layout/chat_conversation_send_area.xml new file mode 100644 index 000000000..f94c6cd5d --- /dev/null +++ b/app/src/main/res/layout/chat_conversation_send_area.xml @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/chat_event.xml b/app/src/main/res/layout/chat_event.xml new file mode 100644 index 000000000..f190bcaa4 --- /dev/null +++ b/app/src/main/res/layout/chat_event.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/dimen.xml b/app/src/main/res/values/dimen.xml index f2251c70e..b2b796838 100644 --- a/app/src/main/res/values/dimen.xml +++ b/app/src/main/res/values/dimen.xml @@ -14,10 +14,12 @@ 24dp 100dp + 24dp 45dp 50dp 100dp 120dp + 5dp 12dp 26dp 2dp