Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ class MergedContactController @Inject constructor(@UserInfoRealm private val use
.sort(MergedContact::name.name)
.sort(MergedContact::comesFromApi.name, Sort.DESCENDING)
}

private fun getMergedContactFromEmailQuery(email: String): RealmQuery<MergedContact> {
return userInfoRealm.query<MergedContact>("${MergedContact::email.name} == $0", email)
}
//endregion

//region Get data
Expand All @@ -69,6 +73,10 @@ class MergedContactController @Inject constructor(@UserInfoRealm private val use
return getMergedContactFromAddressBookQuery(contact).find().map { it }
}

fun getMergedContactFromEmail(email: String): MergedContact? {
return getMergedContactFromEmailQuery(email).find().firstOrNull()
}

fun getMergedContactsAsync(): Flow<ResultsChange<MergedContact>> {
return getMergedContactsQuery().asFlow()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@
*/
package com.infomaniak.mail.ui.newMessage

import android.content.ClipboardManager
import android.content.Context
import android.util.AttributeSet
import android.view.KeyEvent
import android.R as Randroid
import com.google.android.material.textfield.TextInputEditText

class BackspaceAwareTextInput @JvmOverloads constructor(
Expand All @@ -28,13 +30,32 @@ class BackspaceAwareTextInput @JvmOverloads constructor(
) : TextInputEditText(context, attrs) {

private var backspaceOnEmptyField: () -> Unit = {}
private var onPasteIntercept: ((String) -> Boolean)? = null

override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
if (keyCode == KeyEvent.KEYCODE_DEL && text.isNullOrEmpty()) backspaceOnEmptyField()
return super.onKeyDown(keyCode, event)
}

override fun onTextContextMenuItem(id: Int): Boolean {
if (id == Randroid.id.paste) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = clipboard.primaryClip

if (clip != null && clip.itemCount > 0) {
val pastedText = clip.getItemAt(0).text?.toString() ?: ""
if (onPasteIntercept?.invoke(pastedText) == true) return true
}
}

return super.onTextContextMenuItem(id)
}

fun setBackspaceOnEmptyFieldListener(listener: () -> Unit) {
backspaceOnEmptyField = listener
}

fun setOnPasteInterceptListener(listener: (String) -> Boolean) {
onPasteIntercept = listener
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,14 @@ class ContactChipAdapter(
}
}

fun addChips(newRecipients: List<Recipient>): Int {
var added = 0
newRecipients.forEach { recipient -> if (recipients.add(recipient)) added++ }
if (added > 0) notifyItemRangeInserted(itemCount - added, added)

return added
}

fun removeChip(recipient: Recipient) {
val index = recipients.indexOf(recipient)
recipients.remove(recipient)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ class NewMessageRecipientFieldsManager @Inject constructor(private val snackbarM
getAddressBookWithGroupCallback = newMessageViewModel::getAddressBookWithName,
getMergedContactFromContactGroupCallback = newMessageViewModel::getMergedContactFromContactGroup,
getMergedContactFromAddressBookCallback = newMessageViewModel::getMergedContactFromAddressBook,
getMergedContactFromEmailCallback = newMessageViewModel::getMergedContactFromEmail
),
)

Expand All @@ -88,6 +89,7 @@ class NewMessageRecipientFieldsManager @Inject constructor(private val snackbarM
getAddressBookWithGroupCallback = newMessageViewModel::getAddressBookWithName,
getMergedContactFromContactGroupCallback = newMessageViewModel::getMergedContactFromContactGroup,
getMergedContactFromAddressBookCallback = newMessageViewModel::getMergedContactFromAddressBook,
getMergedContactFromEmailCallback = newMessageViewModel::getMergedContactFromEmail
)
)

Expand All @@ -102,6 +104,7 @@ class NewMessageRecipientFieldsManager @Inject constructor(private val snackbarM
getAddressBookWithGroupCallback = newMessageViewModel::getAddressBookWithName,
getMergedContactFromContactGroupCallback = newMessageViewModel::getMergedContactFromContactGroup,
getMergedContactFromAddressBookCallback = newMessageViewModel::getMergedContactFromAddressBook,
getMergedContactFromEmailCallback = newMessageViewModel::getMergedContactFromEmail
)
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,10 @@ class NewMessageViewModel @Inject constructor(
return mergedContactController.getMergedContactFromAddressBook(addressBook)
}

fun getMergedContactFromEmail(email: String): MergedContact? {
return mergedContactController.getMergedContactFromEmail(email)
}

private fun saveNavArgsToSavedState(localUuid: String) {
savedStateHandle[NewMessageActivityArgs::draftLocalUuid.name] = localUuid

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ class RecipientFieldView @JvmOverloads constructor(
private var getAddressBookWithGroup: ((ContactGroup) -> AddressBook?)? = null
private var getMergedContactFromContactGroup: ((ContactGroup) -> List<MergedContact>)? = null
private var getMergedContactFromAddressBook: ((AddressBook) -> List<MergedContact>)? = null
private var getMergedContactFromEmail: ((String) -> MergedContact?)? = null

@Inject
lateinit var snackbarManager: SnackbarManager
Expand Down Expand Up @@ -163,7 +164,7 @@ class RecipientFieldView @JvmOverloads constructor(
onContactClicked = ::contactClicked,
onAddUnrecognizedContact = {
val input = textInput.text.toString()
addRecipient(email = input, name = input)
addRecipientsFromInput(input = input)
},
snackbarManager = snackbarManager,
getAddressBookWithGroup = { getAddressBookWithGroup?.invoke(it) },
Expand All @@ -174,8 +175,7 @@ class RecipientFieldView @JvmOverloads constructor(
onBackspace = { recipient ->
removeRecipient(recipient)
focusTextField()
}
)
})

isSelfCollapsed = true

Expand All @@ -184,6 +184,7 @@ class RecipientFieldView @JvmOverloads constructor(
setToggleRelatedListeners()
setTextInputListeners()
setPopupMenuListeners()
setPasteListeners(textInput)

if (isInEditMode) {
singleChip.root.isVisible = canCollapseEverything
Expand Down Expand Up @@ -260,7 +261,11 @@ class RecipientFieldView @JvmOverloads constructor(

setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_DONE && text?.isNotBlank() == true) {
contactAdapter.addFirstAvailableItem()
if (isAutoCompletionOpened && contactAdapter.itemCount > 0) {
contactAdapter.addFirstAvailableItem()
} else {
addRecipientsFromInput(text.toString())
}
}
true // Keep keyboard open
}
Expand All @@ -286,6 +291,17 @@ class RecipientFieldView @JvmOverloads constructor(
}
}

private fun setPasteListeners(textInput: BackspaceAwareTextInput) {
textInput.setOnPasteInterceptListener { pastedText ->
if (pastedText.contains(EMAIL_SEPARATORS_REGEX)) {
addRecipientsFromInput(pastedText)
true
} else {
false
}
}
}

private fun focusLastChip() {
val count = contactChipAdapter.itemCount
// chipsRecyclerView.children.last() won't work because they are not always ordered correctly
Expand Down Expand Up @@ -379,21 +395,17 @@ class RecipientFieldView @JvmOverloads constructor(
}

private fun addRecipient(email: String, name: String) {

if (!email.isEmail()) {
snackbarManager.setValue(context.getString(R.string.addUnknownRecipientInvalidEmail))
return
}

if (contactChipAdapter.itemCount > MAX_ALLOWED_RECIPIENT) {
if (contactChipAdapter.itemCount >= MAX_ALLOWED_RECIPIENT) {
snackbarManager.setValue(context.getString(R.string.tooManyRecipients))
return
}

if (contactChipAdapter.isEmpty()) {
expand()
binding.chipsRecyclerView.isVisible = true
}
updateChipsVisibility()

val recipientIsNew = contactAdapter.addUsedContact(email)
if (recipientIsNew) {
Expand All @@ -404,6 +416,137 @@ class RecipientFieldView @JvmOverloads constructor(
}
}

private fun addMultipleRecipients(recipients: List<Pair<String, String>>) {
if (recipients.isEmpty()) return

val availableSlots = (MAX_ALLOWED_RECIPIENT - contactChipAdapter.itemCount).coerceAtLeast(0)
val result = processRecipients(recipients, availableSlots)

showWarningSnackbars(
initialRecipientsSize = recipients.size,
initialAvailableSlots = availableSlots,
duplicateCount = result.duplicateCount,
outOfSpaceCount = result.outOfSpaceCount
)

updateChipsVisibility()

contactChipAdapter.addChips(result.acceptedRecipients)
result.acceptedRecipients.forEach { onContactAdded?.invoke(it) }

clearField()
}

private fun processRecipients(
recipients: List<Pair<String, String>>,
initialAvailableSlots: Int
): ProcessedRecipientsResult {
var availableSlots = initialAvailableSlots
var duplicateCount = 0
var outOfSpaceCount = 0

val acceptedRecipients = buildList {
for ((name, email) in recipients) {
if (availableSlots <= 0) {
outOfSpaceCount++
continue
}

if (!contactAdapter.addUsedContact(email)) {
duplicateCount++
continue
}

availableSlots--
add(Recipient().initLocalValues(email, name))
}
}

return ProcessedRecipientsResult(acceptedRecipients, duplicateCount, outOfSpaceCount)
}

private fun showWarningSnackbars(
initialRecipientsSize: Int,
initialAvailableSlots: Int,
duplicateCount: Int,
outOfSpaceCount: Int
) {
if (initialAvailableSlots == 0) {
snackbarManager.setValue(
context.resources.getQuantityString(
R.plurals.tooManyRecipientsPaste,
initialRecipientsSize,
initialRecipientsSize
)
)
return
}

if (outOfSpaceCount > 0) {
snackbarManager.setValue(
context.resources.getQuantityString(
R.plurals.tooManyRecipientsPaste,
outOfSpaceCount,
outOfSpaceCount
)
)
return
}

if (duplicateCount > 0) {
snackbarManager.setValue(
context.resources.getQuantityString(
R.plurals.addMultipleDuplicateEmails,
duplicateCount,
duplicateCount
)
)
return
}
}

private fun updateChipsVisibility() {
if (contactChipAdapter.isEmpty()) {
expand()
binding.chipsRecyclerView.isVisible = true
}
}

fun getContactName(email: String): String {
return getMergedContactFromEmail?.invoke(email)?.name ?: email
}

private fun addRecipientsFromInput(input: String) {
val potentialEmails = input.split(EMAIL_SEPARATORS_REGEX).filter { it.isNotBlank() }.map { it.trim() }

val emailsToAdd = mutableListOf<String>()
val invalidEmails = mutableListOf<String>()

potentialEmails.forEach { email ->
if (email.isEmail()) {
emailsToAdd.add(email)
} else {
invalidEmails.add(email)
}
}

val recipientsToAdd = emailsToAdd.map { email ->
getContactName(email) to email
}

addMultipleRecipients(recipientsToAdd)

if (invalidEmails.isNotEmpty()) {
snackbarManager.setValue(
context.resources.getQuantityString(
R.plurals.addMultipleInvalidEmails,
invalidEmails.size,
invalidEmails.size
)
)
}
}

private fun showContactContextMenu(recipient: Recipient, anchor: BackspaceAwareChip, isForSingleChip: Boolean = false) {
contextMenuBinding.contactDetails.setCorrespondent(recipient)

Expand Down Expand Up @@ -443,6 +586,7 @@ class RecipientFieldView @JvmOverloads constructor(
getAddressBookWithGroup = getAddressBookWithGroupCallback
getMergedContactFromContactGroup = getMergedContactFromContactGroupCallback
getMergedContactFromAddressBook = getMergedContactFromAddressBookCallback
getMergedContactFromEmail = getMergedContactFromEmailCallback
gotFocus = gotFocusCallback
}
}
Expand Down Expand Up @@ -512,14 +656,16 @@ class RecipientFieldView @JvmOverloads constructor(
val onToggleEverythingCallback: ((isCollapsed: Boolean) -> Unit)? = null,
val getAddressBookWithGroupCallback: (ContactGroup) -> AddressBook?,
val getMergedContactFromContactGroupCallback: (ContactGroup) -> List<MergedContact>,
val getMergedContactFromAddressBookCallback: (AddressBook) -> List<MergedContact>
val getMergedContactFromAddressBookCallback: (AddressBook) -> List<MergedContact>,
val getMergedContactFromEmailCallback: ((String) -> MergedContact?)?
)

companion object {
private const val MAX_WIDTH_PERCENTAGE = 0.8f
private const val MAX_ALLOWED_RECIPIENT = 99
private const val EXTERNAL_CHIP_STROKE_WIDTH = 1
private const val NO_STROKE = 0.0f
private val EMAIL_SEPARATORS_REGEX = Regex("""[,;\s\r\n]+""")

fun Chip.setChipStyle(displayAsExternal: Boolean, encryptionStatus: EncryptionStatus) = when {
encryptionStatus == EncryptionStatus.Encrypted -> {
Expand Down Expand Up @@ -548,6 +694,12 @@ class RecipientFieldView @JvmOverloads constructor(
}.applyTo(this)
}

private data class ProcessedRecipientsResult(
val acceptedRecipients: List<Recipient>,
val duplicateCount: Int,
val outOfSpaceCount: Int
)

private data class ChipStyle(
val backgroundColor: Int,
val textColor: Int,
Expand Down
Loading
Loading