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,12 @@ class ContactChipAdapter(
}
}

fun addChips(newRecipients: List<Recipient>) {
var added = 0
newRecipients.forEach { recipient -> if (recipients.add(recipient)) added++ }
if (added > 0) notifyItemRangeInserted(itemCount - added, 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 @@ -184,6 +185,7 @@ class RecipientFieldView @JvmOverloads constructor(
setToggleRelatedListeners()
setTextInputListeners()
setPopupMenuListeners()
setPasteListeners(textInput)

if (isInEditMode) {
singleChip.root.isVisible = canCollapseEverything
Expand Down Expand Up @@ -260,7 +262,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 +292,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 +396,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 +417,122 @@ class RecipientFieldView @JvmOverloads constructor(
}
}

private fun addMultipleRecipients(recipients: List<Recipient>) {
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<Recipient>, initialAvailableSlots: Int): ProcessedRecipientsResult {
var availableSlots = initialAvailableSlots
var duplicateCount = 0
var outOfSpaceCount = 0

val acceptedRecipients = recipients.filter { recipientToAdd ->
if (availableSlots <= 0) {
outOfSpaceCount++
return@filter false
}

if (!contactAdapter.addUsedContact(recipientToAdd.email)) {
duplicateCount++
return@filter false
}

availableSlots--
true
}

return ProcessedRecipientsResult(acceptedRecipients, duplicateCount, outOfSpaceCount)
}

private fun showWarningSnackbars(
initialRecipientsSize: Int,
initialAvailableSlots: Int,
duplicateCount: Int,
outOfSpaceCount: Int,
) {
val warning = when {
initialAvailableSlots == 0 -> {
context.resources.getQuantityString(
R.plurals.tooManyRecipientsPaste,
initialRecipientsSize,
initialRecipientsSize
)
}
outOfSpaceCount > 0 -> {
context.resources.getQuantityString(
R.plurals.tooManyRecipientsPaste,
outOfSpaceCount,
outOfSpaceCount
)
}
duplicateCount > 0 -> {
context.resources.getQuantityString(
R.plurals.addMultipleDuplicateEmails,
duplicateCount,
duplicateCount
)
}
else -> null
}

warning?.let(snackbarManager::setValue)
}

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 { emailToAdd ->
Recipient().initLocalValues(name = getContactName(emailToAdd), email = emailToAdd)
}

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 +572,7 @@ class RecipientFieldView @JvmOverloads constructor(
getAddressBookWithGroup = getAddressBookWithGroupCallback
getMergedContactFromContactGroup = getMergedContactFromContactGroupCallback
getMergedContactFromAddressBook = getMergedContactFromAddressBookCallback
getMergedContactFromEmail = getMergedContactFromEmailCallback
gotFocus = gotFocusCallback
}
}
Expand Down Expand Up @@ -512,14 +642,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 +680,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
12 changes: 12 additions & 0 deletions app/src/main/res/values-da/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@
<string name="actionViewInDark">Vis i mørk tema</string>
<string name="actionViewInLight">Vis i lyst tema</string>
<string name="addLinkTextPlaceholder">Tekst til visning</string>
<plurals name="addMultipleDuplicateEmails">
<item quantity="one">%d email er allerede blevet tilføjet</item>
<item quantity="other">%d emails er allerede blevet tilføjet</item>
</plurals>
<plurals name="addMultipleInvalidEmails">
<item quantity="one">%d email er ugyldig</item>
<item quantity="other">%d emails er ugyldige</item>
</plurals>
<string name="addUnknownRecipientAlreadyUsed">E-mailadressen er allerede i brug</string>
<string name="addUnknownRecipientInvalidEmail">E-mailadressen er ugyldig</string>
<string name="addUnknownRecipientTitle">Tilføj en modtager</string>
Expand Down Expand Up @@ -667,6 +675,10 @@
<string name="toTitle">Til:</string>
<string name="tomorrowMorning">I morgen tidlig</string>
<string name="tooManyRecipients">Du kan ikke tilføje denne adresse, fordi du har nået grænsen for modtagere</string>
<plurals name="tooManyRecipientsPaste">
<item quantity="one">%d adresse kunne ikke tilføjes, fordi du har nået grænsen for modtagere</item>
<item quantity="other">%d adresser kunne ikke tilføjes, fordi du har nået grænsen for modtagere</item>
</plurals>
<string name="trashFolder">Papirkurv</string>
<string name="unblockButton">Fjern blokering</string>
<string name="unknownDialogTitleExpeditor">Ukendt afsender</string>
Expand Down
Loading
Loading