Added edit/retract message features

This commit is contained in:
Sylvain Berfini 2025-07-13 10:39:51 +02:00
parent e8d3c8750a
commit fac6e42c22
21 changed files with 596 additions and 9 deletions

View file

@ -13,6 +13,7 @@ Group changes to describe their impact on the project, as follows:
## [6.1.0] - Unreleased
### Added
- Added the ability to edit/delete chat messages sent less than 24 hours ago.
- Added hover effect when using a mouse (useful for tablets or devices with desktop mode)
- Support right click on some items to open bottom sheet/menu
- Added toggle speaker action in active call notification

View file

@ -481,6 +481,18 @@ class NotificationsManager
}
}
}
@WorkerThread
override fun onMessageRetracted(core: Core, chatRoom: ChatRoom, message: ChatMessage) {
Log.i("$TAG A message has been retracted, checking if notification should be updated")
updateConversationNotification(chatRoom, message)
}
@WorkerThread
override fun onMessageContentEdited(core: Core, chatRoom: ChatRoom, message: ChatMessage) {
Log.i("$TAG A message has been edited, checking if notification should be updated")
updateConversationNotification(chatRoom, message)
}
}
val chatMessageListener: ChatMessageListener = object : ChatMessageListenerStub() {
@ -1100,6 +1112,7 @@ class NotificationsManager
val notifiableMessage = NotifiableMessage(
text,
message.messageId,
contact,
displayName,
address.asStringUriOnly(),
@ -1262,6 +1275,7 @@ class NotificationsManager
val address = message.fromAddress
val notifiableMessage = NotifiableMessage(
text,
message.messageId,
contact,
displayName,
address.asStringUriOnly(),
@ -1637,6 +1651,7 @@ class NotificationsManager
val senderAddress = message.fromAddress
val reply = NotifiableMessage(
text,
message.messageId,
null,
notifiable.myself ?: LinphoneUtils.getDisplayName(senderAddress),
senderAddress.asStringUriOnly(),
@ -1929,6 +1944,47 @@ class NotificationsManager
}
}
@WorkerThread
private fun updateConversationNotification(chatRoom: ChatRoom, message: ChatMessage) {
if (corePreferences.disableChat) return
val chatRoomPeerAddress = chatRoom.peerAddress.asStringUriOnly()
val notifiable: Notifiable? = chatNotificationsMap[chatRoomPeerAddress]
if (notifiable == null) {
Log.i("$TAG No notification for conversation [$chatRoomPeerAddress], nothing to do")
return
}
val found = notifiable.messages.find {
it.messageId == message.messageId
}
if (found != null) {
Log.i("$TAG Edited message is in the currently displayed notification, updating it")
val index = notifiable.messages.indexOf(found)
notifiable.messages.remove(found)
if (!message.isRetracted) {
val notifiableMessage = getNotifiableForChatMessage(message)
notifiable.messages.add(index, notifiableMessage)
}
chatNotificationsMap[chatRoomPeerAddress] = notifiable
val me = coreContext.contactsManager.getMePerson(chatRoom.localAddress)
val pendingIntent = getChatRoomPendingIntent(
chatRoom,
notifiable.notificationId
)
val notification = createMessageNotification(
notifiable,
pendingIntent,
LinphoneUtils.getConversationId(chatRoom),
me
)
notify(notifiable.notificationId, notification, CHAT_TAG)
} else {
Log.i("$TAG Edited/retracted message isn't in currently displayed notification, nothing to update")
}
}
@AnyThread
fun foregroundServiceTypeMaskToString(mask: Int): String {
var stringBuilder = StringBuilder()
@ -1962,6 +2018,7 @@ class NotificationsManager
class NotifiableMessage(
val message: String,
val messageId: String,
var friend: Friend?,
var sender: String,
val senderAddress: String,

View file

@ -94,6 +94,7 @@ import org.linphone.utils.hideKeyboard
import org.linphone.utils.setKeyboardInsetListener
import org.linphone.utils.showKeyboard
import androidx.core.net.toUri
import org.linphone.ui.main.chat.model.MessageDeleteDialogModel
@UiThread
open class ConversationFragment : SlidingPaneChildFragment() {
@ -846,6 +847,22 @@ open class ConversationFragment : SlidingPaneChildFragment() {
}
}
messageLongPressViewModel.editMessageEvent.observe(viewLifecycleOwner) {
it.consume {
val model = messageLongPressViewModel.messageModel.value
if (model != null) {
sendMessageViewModel.editMessage(model)
// Open keyboard & focus edit text
binding.sendArea.messageToSend.showKeyboard()
// Put cursor at the end
coreContext.postOnMainThread {
binding.sendArea.messageToSend.setSelection(binding.sendArea.messageToSend.length())
}
}
}
}
messageLongPressViewModel.replyToMessageEvent.observe(viewLifecycleOwner) {
it.consume {
val model = messageLongPressViewModel.messageModel.value
@ -861,7 +878,7 @@ open class ConversationFragment : SlidingPaneChildFragment() {
it.consume {
val model = messageLongPressViewModel.messageModel.value
if (model != null) {
viewModel.deleteChatMessage(model)
showHowToDeleteMessageDialog(model)
}
}
}
@ -1559,4 +1576,44 @@ open class ConversationFragment : SlidingPaneChildFragment() {
Log.e("$TAG No activity found to handle intent ACTION_CREATE_DOCUMENT: $exception")
}
}
private fun showHowToDeleteMessageDialog(model: MessageModel) {
val canBeRetracted = messageLongPressViewModel.canBeRemotelyDeleted.value == true
val dialogModel = MessageDeleteDialogModel(canBeRetracted)
val dialog = DialogUtils.getHowToDeleteMessageDialog(
requireActivity(),
dialogModel
)
dialogModel.dismissEvent.observe(viewLifecycleOwner) {
it.consume {
dialog.dismiss()
}
}
dialogModel.cancelEvent.observe(viewLifecycleOwner) {
it.consume {
dialog.dismiss()
}
}
dialogModel.deleteLocallyEvent.observe(viewLifecycleOwner) {
it.consume {
Log.i("$TAG Deleting chat message locally")
viewModel.deleteChatMessage(model)
dialog.dismiss()
}
}
dialogModel.deleteForEveryoneEvent.observe(viewLifecycleOwner) {
it.consume {
Log.i("$TAG Deleting chat message (content) for everyone")
viewModel.deleteChatMessageForEveryone(model)
dialog.dismiss()
}
}
dialog.show()
}
}

View file

@ -170,6 +170,22 @@ class ConversationModel
Log.i("$TAG An ephemeral message lifetime has expired, updating last displayed message")
updateLastMessage()
}
@WorkerThread
override fun onMessageRetracted(chatRoom: ChatRoom, message: ChatMessage) {
if (lastMessage == null || message.messageId == lastMessage?.messageId) {
Log.i("$TAG Last message [${message.messageId}] has been retracted")
updateLastMessage()
}
}
@WorkerThread
override fun onMessageContentEdited(chatRoom: ChatRoom, message: ChatMessage) {
if (lastMessage == null || message.messageId == lastMessage?.messageId) {
Log.i("$TAG Last message [${message.messageId}] has been edited")
updateLastMessage()
}
}
}
private val chatMessageListener = object : ChatMessageListenerStub() {
@ -309,7 +325,9 @@ class ConversationModel
lastMessageDeliveryIcon.postValue(LinphoneUtils.getChatIconResId(message.state))
}
if (message.isForward) {
if (message.isRetracted) {
lastMessageContentIcon.postValue(R.drawable.trash)
} else if (message.isForward) {
lastMessageContentIcon.postValue(R.drawable.forward)
} else {
val firstContent = message.contents.firstOrNull()
@ -350,7 +368,7 @@ class ConversationModel
if (message.isOutgoing && message.state != ChatMessage.State.Displayed) {
message.addListener(chatMessageListener)
} else if (message.contents.find { it.isFileTransfer == true } != null) {
} else if (message.contents.find { it.isFileTransfer } != null) {
message.addListener(chatMessageListener)
}

View file

@ -51,7 +51,6 @@ class EventLogModel
EventModel(eventLog)
} else {
val chatMessage = eventLog.chatMessage!!
MessageModel(
chatMessage,
isFromGroup,

View file

@ -0,0 +1,35 @@
package org.linphone.ui.main.chat.model
import androidx.annotation.UiThread
import androidx.lifecycle.MutableLiveData
import org.linphone.utils.Event
class MessageDeleteDialogModel(val canBeRetracted: Boolean) {
val dismissEvent = MutableLiveData<Event<Boolean>>()
val cancelEvent = MutableLiveData<Event<Boolean>>()
val deleteLocallyEvent = MutableLiveData<Event<Boolean>>()
val deleteForEveryoneEvent = MutableLiveData<Event<Boolean>>()
@UiThread
fun dismiss() {
dismissEvent.value = Event(true)
}
@UiThread
fun cancel() {
cancelEvent.value = Event(true)
}
@UiThread
fun deleteLocally() {
deleteLocallyEvent.value = Event(true)
}
@UiThread
fun deleteForEveryone() {
deleteForEveryoneEvent.value = Event(true)
}
}

View file

@ -145,6 +145,10 @@ class MessageModel
val firstFileModel = MediatorLiveData<FileModel>()
val hasBeenEdited = MutableLiveData<Boolean>()
val hasBeenRetracted = MutableLiveData<Boolean>()
val isSelected = MutableLiveData<Boolean>()
// Below are for conferences info
@ -303,6 +307,21 @@ class MessageModel
Log.d("$TAG Ephemeral timer started")
updateEphemeralTimer()
}
@WorkerThread
override fun onContentEdited(message: ChatMessage) {
Log.i("$TAG Message [${message.messageId}] has been edited")
hasBeenEdited.postValue(true)
computeContentsList()
}
@WorkerThread
override fun onRetracted(message: ChatMessage) {
Log.i("$TAG Content(s) of the message have been deleted by it's sender")
hasBeenEdited.postValue(false)
hasBeenRetracted.postValue(true)
computeContentsList()
}
}
init {
@ -320,6 +339,8 @@ class MessageModel
statusIcon.postValue(LinphoneUtils.getChatIconResId(chatMessage.state))
updateReactionsList()
hasBeenEdited.postValue(chatMessage.isEdited && !chatMessage.isRetracted)
hasBeenRetracted.postValue(chatMessage.isRetracted)
computeContentsList()
if (chatMessage.isReply) {
// Wait to see if original message is found before setting isReply to true
@ -421,6 +442,12 @@ class MessageModel
text.postValue(Spannable.Factory.getInstance().newSpannable(""))
filesList.value.orEmpty().forEach(FileModel::destroy)
if (chatMessage.isRetracted) {
meetingFound.postValue(false)
isVoiceRecord.postValue(false)
isTextEmoji.postValue(false)
}
var displayableContentFound = false
var contentIndex = 0
val filesPath = arrayListOf<FileModel>()

View file

@ -48,6 +48,10 @@ class ChatMessageLongPressViewModel : GenericViewModel() {
val isChatRoomReadOnly = MutableLiveData<Boolean>()
val canBeEdited = MutableLiveData<Boolean>()
val canBeRemotelyDeleted = MutableLiveData<Boolean>()
val messageModel = MutableLiveData<MessageModel>()
val isMessageOutgoing = MutableLiveData<Boolean>()
@ -58,6 +62,10 @@ class ChatMessageLongPressViewModel : GenericViewModel() {
MutableLiveData<Event<Boolean>>()
}
val editMessageEvent: MutableLiveData<Event<Boolean>> by lazy {
MutableLiveData<Event<Boolean>>()
}
val replyToMessageEvent: MutableLiveData<Event<Boolean>> by lazy {
MutableLiveData<Event<Boolean>>()
}
@ -76,6 +84,8 @@ class ChatMessageLongPressViewModel : GenericViewModel() {
init {
visible.value = false
canBeEdited.value = false
canBeRemotelyDeleted.value = false
}
@UiThread
@ -87,6 +97,9 @@ class ChatMessageLongPressViewModel : GenericViewModel() {
@UiThread
fun setMessage(model: MessageModel) {
canBeEdited.postValue(model.chatMessage.isEditable)
canBeRemotelyDeleted.postValue(model.chatMessage.isRetractable)
hideCopyTextToClipboard.value = model.text.value.isNullOrEmpty()
isChatRoomReadOnly.value = model.chatRoomIsReadOnly
isMessageOutgoing.value = model.isOutgoing
@ -124,6 +137,13 @@ class ChatMessageLongPressViewModel : GenericViewModel() {
dismiss()
}
@UiThread
fun edit() {
Log.i("$TAG Editing message")
editMessageEvent.value = Event(true)
dismiss()
}
@UiThread
fun copyClickListener() {
Log.i("$TAG Copying message text into clipboard")

View file

@ -305,6 +305,30 @@ class ConversationViewModel
Log.e("$TAG Failed to find matching message in conversation events list")
}
}
@WorkerThread
override fun onMessageRetracted(chatRoom: ChatRoom, message: ChatMessage) {
for (model in eventsList.reversed()) {
if (model.model is MessageModel && model.model.replyToMessageId == message.messageId) {
model.model.computeReplyInfo()
break
}
}
if (message.isOutgoing) {
messageDeletedEvent.postValue(Event(true))
}
}
@WorkerThread
override fun onMessageContentEdited(chatRoom: ChatRoom, message: ChatMessage) {
for (model in eventsList.reversed()) {
if (model.model is MessageModel && model.model.replyToMessageId == message.messageId) {
model.model.computeReplyInfo()
break
}
}
}
}
private val contactsListener = object : ContactsManager.ContactsListener {
@ -455,6 +479,15 @@ class ConversationViewModel
}
}
@UiThread
fun deleteChatMessageForEveryone(chatMessageModel: MessageModel) {
coreContext.postOnCoreThread {
val message = chatMessageModel.chatMessage
Log.i("$TAG Sending order to delete contents of message [${message.messageId}] to every participant of the conversation")
chatRoom.retractMessage(message)
}
}
@UiThread
fun markAsRead() {
if (!isChatRoomInitialized()) return

View file

@ -84,6 +84,10 @@ class SendMessageInConversationViewModel
val attachments = MutableLiveData<ArrayList<FileModel>>()
val isEditing = MutableLiveData<Boolean>()
val isEditingMessage = MutableLiveData<Spannable>()
val isReplying = MutableLiveData<Boolean>()
val isReplyingTo = MutableLiveData<String>()
@ -133,6 +137,8 @@ class SendMessageInConversationViewModel
private var chatMessageToReplyTo: ChatMessage? = null
private var chatMessageToEdit: ChatMessage? = null
private lateinit var voiceMessageRecorder: Recorder
private var voiceRecordAudioFocusRequest: AudioFocusRequestCompat? = null
@ -228,6 +234,28 @@ class SendMessageInConversationViewModel
areFilePickersOpen.value = false
}
@UiThread
fun editMessage(model: MessageModel) {
val newValue = model.text.value?.toString() ?: ""
textToSend.value = newValue
coreContext.postOnCoreThread {
val message = model.chatMessage
Log.i("$TAG Pending message edit [${message.messageId}]")
chatMessageToEdit = message
isEditingMessage.postValue(LinphoneUtils.getFormattedTextDescribingMessage(message))
isEditing.postValue(true)
}
}
@UiThread
fun cancelEdit() {
Log.i("$TAG Cancelling edit")
isEditing.value = false
chatMessageToEdit = null
textToSend.value = ""
}
@UiThread
fun replyToMessage(model: MessageModel) {
coreContext.postOnCoreThread {
@ -253,9 +281,12 @@ class SendMessageInConversationViewModel
val isBasicChatRoom: Boolean = chatRoom.hasCapability(ChatRoom.Capabilities.Basic.toInt())
val messageToReplyTo = chatMessageToReplyTo
val messageToEdit = chatMessageToEdit
val message = if (messageToReplyTo != null) {
Log.i("$TAG Sending message as reply to [${messageToReplyTo.messageId}]")
chatRoom.createReplyMessage(messageToReplyTo)
} else if (messageToEdit != null) {
chatRoom.createReplacesMessage(messageToEdit)
} else {
chatRoom.createEmptyMessage()
}
@ -325,6 +356,7 @@ class SendMessageInConversationViewModel
Log.i("$TAG Message sent, re-setting defaults")
textToSend.postValue("")
isReplying.postValue(false)
isEditing.postValue(false)
isFileAttachmentsListOpen.postValue(false)
isParticipantsListOpen.postValue(false)
isEmojiPickerOpen.postValue(false)
@ -339,6 +371,7 @@ class SendMessageInConversationViewModel
attachments.postValue(attachmentsList)
chatMessageToReplyTo = null
chatMessageToEdit = null
maxNumberOfAttachmentsReached.postValue(false)
}
}

View file

@ -67,6 +67,8 @@ import org.linphone.ui.main.contacts.model.ContactTrustDialogModel
import org.linphone.ui.main.contacts.model.NumberOrAddressPickerDialogModel
import org.linphone.ui.main.model.GroupSetOrEditSubjectDialogModel
import androidx.core.graphics.drawable.toDrawable
import org.linphone.databinding.DialogDeleteChatMessageBinding
import org.linphone.ui.main.chat.model.MessageDeleteDialogModel
class DialogUtils {
companion object {
@ -530,6 +532,22 @@ class DialogUtils {
return getDialog(context, binding)
}
@UiThread
fun getHowToDeleteMessageDialog(
context: Context,
viewModel: MessageDeleteDialogModel
): Dialog {
val binding: DialogDeleteChatMessageBinding = DataBindingUtil.inflate(
LayoutInflater.from(context),
R.layout.dialog_delete_chat_message,
null,
false
)
binding.viewModel = viewModel
return getDialog(context, binding)
}
@UiThread
private fun getDialog(context: Context, binding: ViewDataBinding): Dialog {
val dialog = Dialog(context, R.style.Theme_LinphoneDialog)

View file

@ -607,6 +607,16 @@ class LinphoneUtils {
@WorkerThread
private fun getTextDescribingMessage(message: ChatMessage): Pair<String, String> {
// Check if message is empty (when deleted by it's sender, for everyone)
if (message.isRetracted) {
val text = if (message.isOutgoing) {
AppUtils.getString(R.string.conversation_message_content_deleted_by_us_label)
} else {
AppUtils.getString(R.string.conversation_message_content_deleted_label)
}
return Pair(text, "")
}
// If message contains text, then use that
var text = message.contents.find { content -> content.isText }?.utf8Text ?: ""
var contentDescription = ""

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="256"
android:viewportHeight="256">
<path
android:pathData="M216,48L176,48L176,40a24,24 0,0 0,-24 -24L104,16A24,24 0,0 0,80 40v8L40,48a8,8 0,0 0,0 16h8L48,208a16,16 0,0 0,16 16L192,224a16,16 0,0 0,16 -16L208,64h8a8,8 0,0 0,0 -16ZM96,40a8,8 0,0 1,8 -8h48a8,8 0,0 1,8 8v8L96,48ZM192,208L64,208L64,64L192,64ZM112,104v64a8,8 0,0 1,-16 0L96,104a8,8 0,0 1,16 0ZM160,104v64a8,8 0,0 1,-16 0L144,104a8,8 0,0 1,16 0Z"
android:fillColor="#4e6074"/>
</vector>

View file

@ -235,7 +235,22 @@
android:textColor="?attr/color_main2_800"
android:gravity="center_vertical|start"
android:includeFontPadding="@{!model.isTextEmoji}"
android:visibility="@{model.text.length() > 0 ? View.VISIBLE : View.GONE}"/>
android:visibility="@{model.text.length() > 0 &amp;&amp; !model.hasBeenRetracted ? View.VISIBLE : View.GONE}"/>
<org.linphone.ui.main.chat.view.ChatBubbleTextView
style="@style/default_text_style"
android:id="@+id/retracted_content"
android:onLongClick="@{onLongClickListener}"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/conversation_message_content_deleted_label"
android:textSize="@dimen/chat_bubble_text_size"
android:textColor="?attr/color_main2_500"
android:gravity="center_vertical|start"
android:drawableEnd="@drawable/trash"
android:drawablePadding="5dp"
android:drawableTint="?attr/color_main2_500"
android:visibility="@{model.hasBeenRetracted ? View.VISIBLE : View.GONE, default=gone}"/>
<LinearLayout
android:layout_width="wrap_content"
@ -254,6 +269,16 @@
android:text="@{model.time, default=`13:40`}"
android:textSize="12sp" />
<androidx.appcompat.widget.AppCompatTextView
style="@style/default_text_style_300"
android:id="@+id/edited"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="5dp"
android:text="@string/conversation_message_edited_label"
android:visibility="@{model.hasBeenEdited ? View.VISIBLE : View.GONE, default=gone}"
android:textSize="12sp" />
<ImageView
style="@style/default_text_style_300"
android:id="@+id/delivery_status"

View file

@ -129,6 +129,23 @@
android:background="?attr/color_separator"
android:importantForAccessibility="no"/>
<androidx.appcompat.widget.AppCompatTextView
style="@style/chat_bubble_popup_menu_style"
android:id="@+id/action_edit"
android:onClick="@{() -> viewModel.edit()}"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/menu_edit_chat_message"
android:background="@drawable/action_background_middle"
android:visibility="@{viewModel.isChatRoomReadOnly || !viewModel.canBeEdited ? View.GONE : View.VISIBLE}"
android:drawableEnd="@drawable/pencil_simple" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?attr/color_separator"
android:visibility="@{viewModel.isChatRoomReadOnly || !viewModel.canBeEdited ? View.GONE : View.VISIBLE}"/>
<androidx.appcompat.widget.AppCompatTextView
style="@style/chat_bubble_popup_menu_style"
android:id="@+id/action_reply"

View file

@ -207,7 +207,22 @@
android:textColor="?attr/color_main2_800"
android:gravity="center_vertical|start"
android:includeFontPadding="@{!model.isTextEmoji}"
android:visibility="@{model.text.length() > 0 ? View.VISIBLE : View.GONE}"/>
android:visibility="@{model.text.length() > 0 &amp;&amp; !model.hasBeenRetracted ? View.VISIBLE : View.GONE}"/>
<org.linphone.ui.main.chat.view.ChatBubbleTextView
style="@style/default_text_style"
android:id="@+id/retracted_content"
android:onLongClick="@{onLongClickListener}"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/conversation_message_content_deleted_by_us_label"
android:textSize="@dimen/chat_bubble_text_size"
android:textColor="?attr/color_main2_500"
android:gravity="center_vertical|start"
android:drawableEnd="@drawable/trash"
android:drawablePadding="5dp"
android:drawableTint="?attr/color_main2_500"
android:visibility="@{model.hasBeenRetracted ? View.VISIBLE : View.GONE, default=gone}"/>
<LinearLayout
android:layout_width="wrap_content"
@ -238,6 +253,16 @@
app:tint="?attr/color_main2_600"
android:visibility="@{model.isEphemeral ? View.VISIBLE : View.GONE, default=gone}" />
<androidx.appcompat.widget.AppCompatTextView
style="@style/default_text_style_300"
android:id="@+id/edited"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="5dp"
android:text="@string/conversation_message_edited_label"
android:visibility="@{model.hasBeenEdited ? View.VISIBLE : View.GONE, default=gone}"
android:textSize="12sp" />
<androidx.appcompat.widget.AppCompatTextView
style="@style/default_text_style_300"
android:id="@+id/date_time"

View file

@ -0,0 +1,69 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<import type="android.view.View" />
<variable
name="viewModel"
type="org.linphone.ui.main.chat.viewmodel.SendMessageInConversationViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/color_main2_000">
<View
android:id="@+id/edit_separator"
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?attr/color_separator"
app:layout_constraintTop_toTopOf="parent"/>
<ImageView
android:id="@+id/cancel"
android:onClick="@{() -> viewModel.cancelEdit()}"
android:layout_width="@dimen/icon_size"
android:layout_height="@dimen/icon_size"
android:layout_marginEnd="10dp"
android:src="@drawable/x"
android:contentDescription="@string/content_description_chat_cancel_edit"
app:tint="@color/icon_color_selector"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
<androidx.appcompat.widget.AppCompatTextView
style="@style/default_text_style_300"
android:id="@+id/edit_header"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="20dp"
android:layout_marginTop="10dp"
android:text="@string/conversation_editing_message_title"
android:textSize="12sp"
android:textColor="?attr/color_main2_500"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<androidx.appcompat.widget.AppCompatTextView
style="@style/default_text_style_300"
android:id="@+id/edit_content"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="20dp"
android:layout_marginBottom="10dp"
android:text="@{viewModel.isEditingMessage, default=`Hello this is John! How are you?`}"
android:textSize="12sp"
android:textColor="?attr/color_main2_400"
android:maxLines="1"
android:ellipsize="end"
app:layout_constraintTop_toBottomOf="@id/edit_header"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/cancel" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View file

@ -39,6 +39,13 @@
android:visibility="@{viewModel.isReplying ? View.VISIBLE : View.GONE, default=gone}"
app:layout_constraintTop_toTopOf="parent" />
<include
android:id="@+id/edit_area"
layout="@layout/chat_conversation_edit_area"
bind:viewModel="@{viewModel}"
android:visibility="@{viewModel.isEditing ? View.VISIBLE : View.GONE, default=gone}"
app:layout_constraintTop_toBottomOf="@id/reply_area" />
<include
android:id="@+id/attachments"
layout="@layout/chat_conversation_attachments_area"
@ -46,7 +53,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="@{viewModel.isFileAttachmentsListOpen ? View.VISIBLE : View.GONE, default=gone}"
app:layout_constraintTop_toBottomOf="@id/reply_area" />
app:layout_constraintTop_toBottomOf="@id/edit_area" />
<androidx.emoji2.emojipicker.EmojiPickerView
android:id="@+id/emoji_picker"
@ -169,7 +176,7 @@
android:visibility="@{viewModel.isCallConversation || viewModel.textToSend.length() > 0 || viewModel.attachments.size() > 0 ? View.VISIBLE : View.GONE, default=gone}"
android:onClick="@{() -> viewModel.sendMessage()}"
android:padding="8dp"
android:src="@drawable/paper_plane_right"
android:src="@{viewModel.isEditing ? @drawable/pencil_simple : @drawable/paper_plane_right, default=@drawable/paper_plane_right}"
android:contentDescription="@string/content_description_chat_send_message"
app:layout_constraintBottom_toBottomOf="@id/message_area_background"
app:layout_constraintEnd_toEndOf="@id/message_area_background"

View file

@ -0,0 +1,109 @@
<?xml version="1.0" encoding="utf-8"?>
<layout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<import type="android.view.View" />
<variable
name="viewModel"
type="org.linphone.ui.main.chat.model.MessageDeleteDialogModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:onClick="@{() -> viewModel.dismiss()}"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/dialog_background"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="12dp"
android:layout_marginEnd="12dp"
android:layout_marginBottom="2dp"
android:src="@drawable/shape_dialog_background"
android:contentDescription="@null"
app:layout_constraintWidth_max="@dimen/dialog_max_width"
app:layout_constraintBottom_toBottomOf="@id/anchor"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/title" />
<androidx.appcompat.widget.AppCompatTextView
style="@style/section_header_style"
android:id="@+id/title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="15dp"
android:paddingTop="@dimen/dialog_top_bottom_margin"
android:text="@string/conversation_dialog_delete_chat_message_title"
app:layout_constraintVertical_chainStyle="packed"
app:layout_constraintBottom_toTopOf="@id/cancel"
app:layout_constraintStart_toStartOf="@id/dialog_background"
app:layout_constraintEnd_toEndOf="@id/dialog_background"
app:layout_constraintTop_toTopOf="parent" />
<androidx.appcompat.widget.AppCompatTextView
android:onClick="@{() -> viewModel.deleteForEveryone()}"
style="@style/primary_dialog_button_label_style"
android:id="@+id/delete_for_everyone"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="8dp"
android:text="@string/conversation_dialog_delete_for_everyone_label"
android:maxLines="1"
android:ellipsize="end"
android:enabled="@{viewModel.canBeRetracted}"
app:layout_constrainedWidth="true"
app:layout_constraintHorizontal_bias="1"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toStartOf="@id/dialog_background"
app:layout_constraintEnd_toStartOf="@id/delete_locally"
app:layout_constraintTop_toTopOf="@id/cancel"
app:layout_constraintBottom_toBottomOf="@id/cancel"/>
<androidx.appcompat.widget.AppCompatTextView
android:onClick="@{() -> viewModel.deleteLocally()}"
style="@style/primary_dialog_button_label_style"
android:id="@+id/delete_locally"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:text="@string/conversation_dialog_delete_locally_label"
app:layout_constrainedWidth="true"
app:layout_constraintStart_toEndOf="@id/delete_for_everyone"
app:layout_constraintEnd_toStartOf="@id/cancel"
app:layout_constraintTop_toTopOf="@id/cancel"
app:layout_constraintBottom_toBottomOf="@id/cancel"/>
<androidx.appcompat.widget.AppCompatTextView
android:onClick="@{() -> viewModel.dismiss()}"
style="@style/secondary_dialog_button_label_style"
android:id="@+id/cancel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:layout_marginStart="8dp"
android:layout_marginEnd="16dp"
android:text="@string/dialog_cancel"
app:layout_constrainedWidth="true"
app:layout_constraintStart_toEndOf="@id/delete_locally"
app:layout_constraintEnd_toEndOf="@id/dialog_background"
app:layout_constraintTop_toBottomOf="@id/title"
app:layout_constraintBottom_toTopOf="@id/anchor"/>
<View
android:id="@+id/anchor"
android:layout_width="wrap_content"
android:layout_height="@dimen/dialog_top_bottom_margin"
app:layout_constraintTop_toBottomOf="@id/cancel"
app:layout_constraintStart_toStartOf="@id/dialog_background"
app:layout_constraintEnd_toEndOf="@id/dialog_background"
app:layout_constraintBottom_toBottomOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View file

@ -548,6 +548,13 @@
<string name="conversation_pick_any_file_label">Choisir un fichier</string>
<string name="conversation_file_cant_be_opened_error_toast">Impossible d\'ouvrir le fichier !</string>
<string name="conversation_pdf_file_cant_be_opened_error_toast">Impossible d\'ouvrir un PDF protégé par mot de passe</string>
<string name="conversation_editing_message_title">Modification du message</string>
<string name="conversation_message_edited_label">Modifié</string>
<string name="conversation_dialog_delete_chat_message_title">Supprimer le message ?</string>
<string name="conversation_dialog_delete_locally_label">Pour moi</string>
<string name="conversation_dialog_delete_for_everyone_label">Pour tout le monde</string>
<string name="conversation_message_content_deleted_label"><i>Le message a été supprimé</i></string>
<string name="conversation_message_content_deleted_by_us_label"><i>Vous avez supprimé le message</i></string>
<string name="conversation_info_participants_list_title">Participants (%s)</string>
<string name="conversation_info_add_participants_label">Ajouter des participants</string>
@ -805,6 +812,7 @@
<string name="menu_invite">Inviter</string>
<string name="menu_resend_chat_message">Ré-envoyer</string>
<string name="menu_show_imdn">Info de réception</string>
<string name="menu_edit_chat_message">Modifier</string>
<string name="menu_reply_to_chat_message">Répondre</string>
<string name="menu_forward_chat_message">Transférer</string>
<string name="menu_copy_chat_message">Copier le texte</string>
@ -913,6 +921,7 @@
<string name="content_description_chat_start_voice_message_recording">Démarre l\'enregistrement d\'un message vocal</string>
<string name="content_description_chat_send_message">Envoie le message</string>
<string name="content_description_chat_cancel_reply">Le message ne sera plus une réponse à un précédent message</string>
<string name="content_description_chat_cancel_edit">Annule l\'édition du message</string>
<string name="content_description_chat_open_emoji_picker">Ouvre le selectionneur d\'emoji</string>
<string name="content_description_chat_open_attach_file">Ouvre le selectionneur de fichier</string>
<string name="content_description_chat_edit_conversation_subject">Cliquez pour modifier le sujet de la conversation</string>

View file

@ -28,7 +28,7 @@
<string name="emoji_tear" translatable="false">😢</string>
<string name="help_about_open_source_licenses_title" translatable="false">GNU General Public License v3.0</string>
<string name="help_about_open_source_licenses_subtitle" translatable="false">© Belledonne Communications 2010-2024</string>
<string name="help_about_open_source_licenses_subtitle" translatable="false">© Belledonne Communications 2010-2025</string>
<string name="help_advanced_send_debug_logs_email_address" translatable="false">linphone-android@belledonne-communications.com</string>
<string name="website_contact_url" translatable="false">https://linphone.org/contact</string>
@ -591,6 +591,13 @@
<string name="conversation_pick_any_file_label">Pick file</string>
<string name="conversation_file_cant_be_opened_error_toast">File can\'t be opened!</string>
<string name="conversation_pdf_file_cant_be_opened_error_toast">Can\'t open password protected PDFs yet</string>
<string name="conversation_editing_message_title">Message being edited</string>
<string name="conversation_message_edited_label">Edited</string>
<string name="conversation_dialog_delete_chat_message_title">Delete this message?</string>
<string name="conversation_dialog_delete_locally_label">For me</string>
<string name="conversation_dialog_delete_for_everyone_label">For everyone</string>
<string name="conversation_message_content_deleted_label"><i>This message has been deleted</i></string>
<string name="conversation_message_content_deleted_by_us_label"><i>You have deleted this message</i></string>
<string name="conversation_info_participants_list_title">Group members (%s)</string>
<string name="conversation_info_add_participants_label">Add participants</string>
@ -848,6 +855,7 @@
<string name="menu_invite">Invite</string>
<string name="menu_resend_chat_message">Re-send</string>
<string name="menu_show_imdn">Delivery status</string>
<string name="menu_edit_chat_message">Edit</string>
<string name="menu_reply_to_chat_message">Reply</string>
<string name="menu_forward_chat_message">Forward</string>
<string name="menu_copy_chat_message">Copy</string>
@ -956,6 +964,7 @@
<string name="content_description_chat_start_voice_message_recording">Starts recording a voice message</string>
<string name="content_description_chat_send_message">Sends message in conversation</string>
<string name="content_description_chat_cancel_reply">Message will no longer be a reply to a previous message</string>
<string name="content_description_chat_cancel_edit">Cancels message edition</string>
<string name="content_description_chat_open_emoji_picker">Opens emoji picker</string>
<string name="content_description_chat_open_attach_file">Opens file picker</string>
<string name="content_description_chat_edit_conversation_subject">Click to edit the subject of this conversation</string>