Filter participants list using user input after '@'

This commit is contained in:
Sylvain Berfini 2026-01-05 14:55:03 +01:00
parent b88b6a8093
commit 50aa053c19
8 changed files with 102 additions and 17 deletions

View file

@ -21,6 +21,7 @@ Group changes to describe their impact on the project, as follows:
- Support right click on some items to open bottom sheet/menu
- Added toggle speaker action in active call notification
- Increased text size for chat messages that only contains emoji(s)
- Use user-input to filter participants list after typing "@" in conversation send area
- Handle read-only CardDAV address books, disable edit/delete menus for contacts in read-only FriendList
- Added swipe/pull to refresh on contacts list of a CardDAV addressbook has been configured to force the synchronization
- Show information to user when filtering contacts doesn't show them all and user may have to refine it's search
@ -52,6 +53,9 @@ Group changes to describe their impact on the project, as follows:
- Added more info into StartupListener logs
- Updated password forgotten procedure, will use online account manager platform
### Fixed
- Copy raw message content instead of modified one when it contains a participant mention ("@username")
## [6.0.21] - 2025-12-16
### Added

View file

@ -283,14 +283,23 @@ open class ConversationFragment : SlidingPaneChildFragment() {
override fun afterTextChanged(editable: Editable?) {
if (viewModel.isGroup.value == true) {
sendMessageViewModel.closeParticipantsList()
val split = editable.toString().split(" ")
for (part in split) {
if (part == "@") {
if (split.isNotEmpty()) {
val lastPart = split.last()
if (lastPart.isNotEmpty() && lastPart.startsWith("@")) {
coreContext.postOnCoreThread {
val filter = if (lastPart.length > 1) lastPart.substring(1) else ""
sendMessageViewModel.filterParticipantsList(filter)
}
if (sendMessageViewModel.isParticipantsListOpen.value == false) {
Log.i("$TAG '@' found, opening participants list")
sendMessageViewModel.openParticipantsList()
}
} else if (sendMessageViewModel.isParticipantsListOpen.value == true) {
Log.i("$TAG Closing participants list")
sendMessageViewModel.closeParticipantsList()
}
}
}

View file

@ -25,6 +25,7 @@ import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.style.StyleSpan
import androidx.annotation.AnyThread
import androidx.annotation.UiThread
import androidx.annotation.WorkerThread
import androidx.lifecycle.MediatorLiveData
@ -151,6 +152,8 @@ class MessageModel
val isSelected = MutableLiveData<Boolean>()
private var rawTextContent: String = ""
// Below are for conferences info
val meetingFound = MutableLiveData<Boolean>()
@ -436,6 +439,11 @@ class MessageModel
avatarModel.postValue(avatar)
}
@AnyThread
fun getRawTextContent(): String {
return rawTextContent
}
@WorkerThread
private fun computeContentsList() {
Log.d("$TAG Computing message contents list")
@ -686,10 +694,10 @@ class MessageModel
@WorkerThread
private fun computeTextContent(content: Content, highlight: String) {
val textContent = content.utf8Text.orEmpty().trim()
val spannableBuilder = SpannableStringBuilder(textContent)
rawTextContent = content.utf8Text.orEmpty().trim()
val spannableBuilder = SpannableStringBuilder(rawTextContent)
val emojiOnly = AppUtils.isTextOnlyContainsEmoji(textContent)
val emojiOnly = AppUtils.isTextOnlyContainsEmoji(rawTextContent)
isTextEmoji.postValue(emojiOnly)
if (emojiOnly) {
text.postValue(spannableBuilder)
@ -698,7 +706,7 @@ class MessageModel
// Check for search
if (highlight.isNotEmpty()) {
val indexStart = textContent.indexOf(highlight, 0, ignoreCase = true)
val indexStart = rawTextContent.indexOf(highlight, 0, ignoreCase = true)
if (indexStart >= 0) {
isTextHighlighted = true
val indexEnd = indexStart + highlight.length
@ -713,12 +721,12 @@ class MessageModel
// Check for mentions
val chatRoom = chatMessage.chatRoom
val matcher = Pattern.compile(MENTION_REGEXP).matcher(textContent)
val matcher = Pattern.compile(MENTION_REGEXP).matcher(rawTextContent)
var offset = 0
while (matcher.find()) {
val start = matcher.start()
val end = matcher.end()
val source = textContent.subSequence(start + 1, end) // +1 to remove @
val source = rawTextContent.subSequence(start + 1, end) // +1 to remove @
Log.d("$TAG Found mention [$source]")
// Find address matching username

View file

@ -150,7 +150,7 @@ class ChatMessageLongPressViewModel : GenericViewModel() {
fun copyClickListener() {
Log.i("$TAG Copying message text into clipboard")
val text = messageModel.value?.text?.value?.toString()
val text = messageModel.value?.getRawTextContent()
val clipboard = coreContext.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val label = "Message"
clipboard.setPrimaryClip(ClipData.newPlainText(label, text))

View file

@ -110,6 +110,8 @@ class SendMessageInConversationViewModel
val voiceRecordPlayerPosition = MutableLiveData<Int>()
val isComputingParticipantsList = MutableLiveData<Boolean>()
private lateinit var voiceRecordPlayer: Player
private val playerListener = PlayerListener {
@ -143,6 +145,8 @@ class SendMessageInConversationViewModel
private var voiceRecordAudioFocusRequest: AudioFocusRequestCompat? = null
private var participantsListFilter = ""
private val chatRoomListener = object : ChatRoomListenerStub() {
@WorkerThread
override fun onParticipantAdded(chatRoom: ChatRoom, eventLog: EventLog) {
@ -163,6 +167,7 @@ class SendMessageInConversationViewModel
isKeyboardOpen.value = false
isEmojiPickerOpen.value = false
areFilePickersOpen.value = false
isParticipantsListOpen.value = false
isVoiceRecording.value = false
isPlayingVoiceRecord.value = false
isCallConversation.value = false
@ -409,6 +414,10 @@ class SendMessageInConversationViewModel
@UiThread
fun closeParticipantsList() {
isParticipantsListOpen.value = false
coreContext.postOnCoreThread {
participantsListFilter = ""
computeParticipantsList()
}
}
@UiThread
@ -571,7 +580,32 @@ class SendMessageInConversationViewModel
}
@WorkerThread
private fun computeParticipantsList() {
fun filterParticipantsList(filter: String) {
Log.i("$TAG Filtering participants list using user-input [$filter]")
if (filter.isEmpty() && participantsListFilter.isNotEmpty()) {
participantsListFilter = ""
computeParticipantsList()
return
}
if (filter.length >= participantsListFilter.length) {
isComputingParticipantsList.postValue(true)
participantsListFilter = filter
val currentList = participants.value.orEmpty()
val newList = currentList.filter {
it.address.asStringUriOnly().contains(filter) || it.avatarModel.contactName?.contains(filter) == true
}
participants.postValue(newList as ArrayList<ParticipantModel>)
isComputingParticipantsList.postValue(false)
} else {
participantsListFilter = filter
computeParticipantsList(filter)
}
}
@WorkerThread
private fun computeParticipantsList(filter: String = "") {
isComputingParticipantsList.postValue(true)
val participantsList = arrayListOf<ParticipantModel>()
for (participant in chatRoom.participants) {
@ -580,14 +614,18 @@ class SendMessageInConversationViewModel
coreContext.postOnCoreThread {
val username = clicked.address.username
if (!username.isNullOrEmpty()) {
participantUsernameToAddEvent.postValue(Event(username))
participantUsernameToAddEvent.postValue(Event(username.substring(participantsListFilter.length)))
}
}
})
if (filter.isEmpty() || participant.address.asStringUriOnly().contains(filter) || model.avatarModel.contactName?.contains(filter) == true) {
participantsList.add(model)
}
}
participants.postValue(participantsList)
isComputingParticipantsList.postValue(false)
}
@WorkerThread

View file

@ -22,6 +22,18 @@
android:importantForAccessibility="no"
app:layout_constraintTop_toTopOf="parent"/>
<androidx.appcompat.widget.AppCompatTextView
style="@style/default_text_style"
android:id="@+id/participants_header"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:text="@string/conversation_participants_list_header"
android:textSize="12sp"
android:textColor="?attr/color_main2_500"
app:layout_constraintTop_toBottomOf="@id/participants_separator"
app:layout_constraintStart_toStartOf="parent" />
<androidx.core.widget.NestedScrollView
android:id="@+id/participants"
android:layout_width="0dp"
@ -29,7 +41,7 @@
app:layout_constraintHeight_max="@dimen/chat_room_participants_list_max_height"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/participants_separator">
app:layout_constraintTop_toBottomOf="@id/participants_header">
<LinearLayout
android:layout_width="match_parent"
@ -41,6 +53,18 @@
</androidx.core.widget.NestedScrollView>
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/fetch_in_progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:indeterminate="true"
android:visibility="@{viewModel.isComputingParticipantsList ? View.VISIBLE : View.GONE}"
app:indicatorColor="?attr/color_main1_500"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/participants"
app:layout_constraintBottom_toBottomOf="@id/participants" />
<ImageView
android:id="@+id/participants_close"
android:onClick="@{() -> viewModel.closeParticipantsList()}"

View file

@ -566,6 +566,7 @@
<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_participants_list_header">Participants</string>
<string name="conversation_info_participants_list_title">Participants (%s)</string>
<string name="conversation_info_add_participants_label">Ajouter des participants</string>

View file

@ -609,6 +609,7 @@
<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_participants_list_header">Participants</string>
<string name="conversation_info_participants_list_title">Group members (%s)</string>
<string name="conversation_info_add_participants_label">Add participants</string>