Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
36f5822
feat: Add delay in draft call
Elouan1411 May 19, 2026
9e9d7f6
refactor: Rename RCore to RCoreLegacy
Elouan1411 May 19, 2026
e6d5285
feat: Add UI for choose delay
Elouan1411 May 19, 2026
4510ada
feat: Add snackbar with cancel button
Elouan1411 May 19, 2026
7959af4
feat: Add length parameter to snackbar
Elouan1411 May 19, 2026
72bc581
feat: Add string for when canceling the send
Elouan1411 May 19, 2026
84e2626
feat: Add comment to explain why minus 2 seconds
Elouan1411 May 19, 2026
226d7b9
feat: Add snackbar when email is cancelled
Elouan1411 May 19, 2026
025baa0
feat: Display the word "draft" in the thread list only if drafts are …
Elouan1411 May 20, 2026
837811b
fix: Display draft only when it is not a scheduled message
Elouan1411 May 20, 2026
cb8b316
fix: Revert UI to draft state when canceling
Elouan1411 May 20, 2026
714da1c
fix: Set expanded to false for the scheduled message that just revert…
Elouan1411 May 20, 2026
c92d71d
feat: Display sending message in the message header
Elouan1411 May 20, 2026
14fc576
fix: Refresh draft folder and update UI when cancel button is pressed
Elouan1411 May 20, 2026
0768373
fix: Refresh draft folder after email is successfully sent
Elouan1411 May 20, 2026
3b002e1
refactor: Clean code
Elouan1411 May 20, 2026
e952360
fix: Correct call in test
Elouan1411 May 20, 2026
ae1d1a1
refactor: Clean code
Elouan1411 May 26, 2026
33e2145
fix: Refresh sent folder after sending delay
Elouan1411 May 28, 2026
34582c2
refactor: Clean code
Elouan1411 Jun 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,10 @@ object ApiRepository : ApiRepositoryCore() {
return callApi(ApiRoutes.resource(unscheduleDraftUrl), DELETE)
}

suspend fun unsendMessage(unsendMessageUrl: String): ApiResponse<Boolean> {
return callApi(ApiRoutes.resource(unsendMessageUrl), PUT)
}

suspend fun rescheduleDraft(draftResource: String, scheduleDate: Date): ApiResponse<Unit> {
return callApi(ApiRoutes.rescheduleDraft(draftResource, scheduleDate), PUT)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,7 @@ class ThreadController @Inject constructor(private val mailboxContentRealm: Real
// If we've already got this Message's Draft beforehand, we need to save
// its `draftLocalUuid`, otherwise we'll lose the link between them.
private fun Message.getDraftLocalUuidBlocking(realm: TypedRealm): String? {
return if (isDraft) DraftController.getDraftByMessageUidBlocking(uid, realm)?.localUuid else null
return if (isDraft && !isScheduledMessage) DraftController.getDraftByMessageUidBlocking(uid, realm)?.localUuid else null
}

fun deleteSearchThreads(realm: MutableRealm) = with(realm) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ object ThreadRecomputations {
if (!message.isSeen) unseenMessagesCount++
from += message.from
to += message.to
if (message.isDraft) hasDrafts = true
if (message.isDraft && !message.isScheduledMessage) hasDrafts = true
if (message.isFavorite) isFavorite = true
if (message.isAnswered) {
isAnswered = true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,7 @@ import kotlinx.serialization.Serializable
data class SendDraftResult(
@SerialName("etop")
val scheduledMessageEtop: String,

@SerialName("cancel_resource")
val cancelResourceUrl: String?,
)
Original file line number Diff line number Diff line change
Expand Up @@ -432,7 +432,7 @@ class Message : RealmObject, Snoozable {

fun hasUnreadContent() = !isSeen || emojiReactions.any { !it.isSeen }

fun shouldBeExpanded(index: Int, lastIndex: Int) = !isDraft && (hasUnreadContent() || index == lastIndex)
fun shouldBeExpanded(index: Int, lastIndex: Int) = (!isDraft || isScheduledMessage) && (hasUnreadContent() || index == lastIndex)

fun toThread() = Thread().apply {
uid = this@Message.uid
Expand Down
26 changes: 21 additions & 5 deletions app/src/main/java/com/infomaniak/mail/ui/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,9 @@ import java.util.Locale
import java.util.UUID
import javax.inject.Inject
import kotlin.coroutines.resume
import com.infomaniak.core.legacy.R as RCore
import kotlin.math.max
import com.infomaniak.core.common.R as RCore
import com.infomaniak.core.legacy.R as RCoreLegacy

@AndroidEntryPoint
class MainActivity : BaseActivity() {
Expand Down Expand Up @@ -351,7 +353,10 @@ class MainActivity : BaseActivity() {
}
}
DraftAction.SEND, DraftAction.SEND_REACTION -> {
showSentDraftSnackbar()
// Waits 2s after cancel delay to guarantee the send action is committed
mainViewModel.refreshFoldersAfterSendDelay(localSettings.cancelDelay + 2)
val cancelResourceUrl = getString(DraftsActionsWorker.CANCEL_RESOURCE_URL_KEY)
showSentDraftSnackbar(cancelResourceUrl)
}
DraftAction.SCHEDULE -> {
val scheduleDate = getString(DraftsActionsWorker.SCHEDULED_DRAFT_DATE_KEY)
Expand Down Expand Up @@ -389,9 +394,20 @@ class MainActivity : BaseActivity() {
}

// Still display the Snackbar even if it took three times 10 seconds of timeout to succeed
private fun showSentDraftSnackbar() {
private fun showSentDraftSnackbar(cancelResourceUrl: String?) {
showSendingSnackbarTimer.cancel()
snackbarManager.setValue(getString(R.string.snackbarEmailSent))
if (cancelResourceUrl == null) {
snackbarManager.setValue(getString(R.string.snackbarEmailSent))
} else {
snackbarManager.setValue(
title = getString(R.string.snackbarEmailSent),
buttonTitle = RCore.string.buttonCancel,
customBehavior = { mainViewModel.unsendMessage(cancelResourceUrl) },
// Snackbar displays for 2 seconds less than actual cancel delay
// to ensure the user sees the snackbar disappear before the action is committed
length = max(0, (localSettings.cancelDelay - 2) * 1000)
)
}
}

// Still display the Snackbar even if it took three times 10 seconds of timeout to succeed
Expand Down Expand Up @@ -555,7 +571,7 @@ class MainActivity : BaseActivity() {
trackInAppUpdateEvent(if (isWantingUpdate) MatomoName.DiscoverNow else MatomoName.DiscoverLater)
},
onInstallStart = { trackInAppUpdateEvent(MatomoName.InstallUpdate) },
onInstallFailure = { snackbarManager.setValue(getString(RCore.string.errorUpdateInstall)) },
onInstallFailure = { snackbarManager.setValue(getString(RCoreLegacy.string.errorUpdateInstall)) },
Comment thread
Elouan1411 marked this conversation as resolved.
onInAppUpdateUiChange = { isUpdateDownloaded ->
SentryLog.d(APP_UPDATE_TAG, "Must display update button : $isUpdateDownloaded")
mainViewModel.canInstallUpdate.value = isUpdateDownloaded
Expand Down
25 changes: 25 additions & 0 deletions app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -852,6 +852,31 @@ class MainViewModel @Inject constructor(
showUnscheduledDraftSnackbar(apiResponse)
}

fun unsendMessage(unsendMessageUrl: String) = viewModelScope.launch(ioCoroutineContext) {
val apiResponse = ApiRepository.unsendMessage(unsendMessageUrl)
if (apiResponse.isSuccess()) {
snackbarManager.postValue(appContext.getString(R.string.snackbarSendCancelled))
val draftsFolderId = folderController.getFolder(FolderRole.DRAFT)?.id ?: return@launch
Comment thread
Elouan1411 marked this conversation as resolved.

currentMailbox.value?.let { mailbox ->
refreshFoldersAsync(mailbox, ImpactedFolders(mutableSetOf(draftsFolderId)))
}
} else {
snackbarManager.postValue(appContext.getString(R.string.errorSnoozeFailedCancel))
}
}

fun refreshFoldersAfterSendDelay(sendDelay: Int) = viewModelScope.launch(ioCoroutineContext) {
delay(sendDelay * 1_000L)
currentMailbox.value?.let { mailbox ->
refreshFoldersAsync(
mailbox,
ImpactedFolders(mutableSetOf(FolderRole.DRAFT, FolderRole.SENT)),
destinationFolderId = folderController.getFolder(FolderRole.SENT)?.id,
)
}
}

private fun showUnscheduledDraftSnackbar(apiResponse: ApiResponse<Unit>) {

fun openDraftFolder() = viewModelScope.launch { folderController.getFolder(FolderRole.DRAFT)?.id?.let(::openFolder) }
Expand Down
24 changes: 19 additions & 5 deletions app/src/main/java/com/infomaniak/mail/ui/main/SnackbarManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ class SnackbarManager @Inject constructor() {
onUndoData: ((data: UndoData) -> Unit)? = null,
) = with(snackbarFeedback) {
removeObservers(activity)
observe(activity) { (title, undoData, buttonTitleRes, customBehavior) ->
observe(activity) { (title, undoData, buttonTitleRes, customBehavior, length) ->
val action: (() -> Unit)? = if (undoData != null) {
{ onUndoData?.invoke(undoData) }
} else {
Expand All @@ -59,6 +59,7 @@ class SnackbarManager @Inject constructor() {
anchor = getAnchor?.invoke(),
actionButtonTitle = buttonTitle,
onActionClicked = safeAction,
length = length,
)
}
}
Expand All @@ -75,19 +76,32 @@ class SnackbarManager @Inject constructor() {
}
}

fun setValue(title: String, undoData: UndoData? = null, buttonTitle: Int? = null, customBehavior: (() -> Unit)? = null) {
snackbarFeedback.value = SnackbarData(title, undoData, buttonTitle, customBehavior)
fun setValue(
title: String,
undoData: UndoData? = null,
buttonTitle: Int? = null,
customBehavior: (() -> Unit)? = null,
length: Int = Snackbar.LENGTH_LONG,
) {
snackbarFeedback.value = SnackbarData(title, undoData, buttonTitle, customBehavior, length)
}

fun postValue(title: String, undoData: UndoData? = null, buttonTitle: Int? = null, customBehavior: (() -> Unit)? = null) {
snackbarFeedback.postValue(SnackbarData(title, undoData, buttonTitle, customBehavior))
fun postValue(
title: String,
undoData: UndoData? = null,
buttonTitle: Int? = null,
customBehavior: (() -> Unit)? = null,
length: Int = Snackbar.LENGTH_LONG,
) {
snackbarFeedback.postValue(SnackbarData(title, undoData, buttonTitle, customBehavior, length))
}

private data class SnackbarData(
val title: String,
val undoData: UndoData?,
@StringRes val buttonTitle: Int?,
val customBehavior: (() -> Unit)?,
val length: Int,
)

data class UndoData(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,12 @@ class SettingsFragment : Fragment() {
settingsThreadMode.setSubtitle(threadMode.localisedNameRes)
settingsExternalContent.setSubtitle(externalContent.localisedNameRes)
settingsAutomaticAdvance.setSubtitle(autoAdvanceMode.localisedNameRes)
val cancelDelaySubtitle = if (cancelDelay == 0) {
getString(R.string.settingsDisabled)
} else {
getString(R.string.settingsDelaySeconds, cancelDelay)
}
settingsCancellationPeriod.setSubtitle(cancelDelaySubtitle)
lifecycleScope.launch {
UserDatabase().userDao().allUsers.map { list -> list.any { it.isStaff } }.collectLatest { hasStaffAccount ->
if (!hasStaffAccount) return@collectLatest
Expand Down Expand Up @@ -238,6 +244,10 @@ class SettingsFragment : Fragment() {
safelyAnimatedNavigation(SettingsFragmentDirections.actionSettingsToAutoAdvanceSettings(), currentClassName)
}

settingsCancellationPeriod.setOnClickListener {
safelyAnimatedNavigation(SettingsFragmentDirections.actionSettingsToCancelDelaySetting(), currentClassName)
}

settingsThreadListDensity.setOnClickListener {
safelyAnimatedNavigation(SettingsFragmentDirections.actionSettingsToThreadListDensitySetting(), currentClassName)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ import com.infomaniak.core.legacy.utils.safeBinding
import com.infomaniak.mail.R
import com.infomaniak.mail.data.LocalSettings
import com.infomaniak.mail.databinding.FragmentCancelDelaySettingBinding
import com.infomaniak.mail.utils.extensions.notYetImplemented
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject

Expand All @@ -49,8 +48,7 @@ class CancelDelaySettingFragment : Fragment() {
checkInitialValue()

binding.radioGroup.onItemCheckedListener { _, value, _ ->
notYetImplemented()
val seconds = value?.toInt() ?: throw NullPointerException("Radio button had no associated value")
val seconds = value?.toInt() ?: return@onItemCheckedListener
localSettings.cancelDelay = seconds
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,9 @@ class ThreadAdapter(
}

private fun initMapForNewMessage(message: Message, position: Int) = with(threadAdapterState) {
if (isExpandedMap[message.uid] == null) {
val wasScheduledMessageNowDraft = isExpandedMap[message.uid] != null && message.isDraft && !message.isScheduledMessage

if (isExpandedMap[message.uid] == null || wasScheduledMessageNowDraft) {
isExpandedMap[message.uid] = message.shouldBeExpanded(position, items.lastIndex)
}

Expand Down Expand Up @@ -431,7 +433,7 @@ class ThreadAdapter(
private fun MessageViewHolder.bindHeader(message: Message) = with(binding) {
val messageDate = message.displayDate.toDate()

if (message.isDraft) {
if (message.isDraft && !message.isScheduledMessage) {
userAvatar.loadUserAvatar(AccountUtils.currentUser!!)
expeditorName.apply {
text = context.getString(R.string.messageIsDraftOption)
Expand Down Expand Up @@ -485,7 +487,7 @@ class ThreadAdapter(
isExpandedMap[message.uid] = false
onExpandOrCollapseMessage(message, shouldTrack = true)
} else {
if (message.isDraft) {
if (message.isDraft && !message.isScheduledMessage) {
threadAdapterCallbacks?.onDraftClicked?.invoke(message)
} else {
isExpandedMap[message.uid] = true
Expand Down Expand Up @@ -917,17 +919,18 @@ class ThreadAdapter(

private fun ItemMessageBinding.setHeaderState(message: Message, isExpanded: Boolean) {
deleteDraftButton.apply {
isVisible = message.isDraft
isVisible = message.isDraft && !message.isScheduledMessage
setOnClickListener { threadAdapterCallbacks?.onDeleteDraftClicked?.invoke(message) }
}
replyButton.apply {
isVisible = isExpanded && message.isScheduledDraft.not()
isVisible = isExpanded && !message.isScheduledDraft && !message.isScheduledMessage
setOnClickListener { threadAdapterCallbacks?.onReplyClicked?.invoke(message) }
}
menuButton.apply {
isVisible = isExpanded && message.isScheduledDraft.not()
isVisible = isExpanded && !message.isScheduledDraft && !message.isScheduledMessage
setOnClickListener { threadAdapterCallbacks?.onMenuClicked?.invoke(message) }
}
sendingProgressText.isVisible = message.isScheduledMessage

recipient.text = if (isExpanded) getAllRecipientsFormatted(message) else context.formatSubject(message.subject)
recipientChevron.isVisible = isExpanded
Expand Down Expand Up @@ -1086,7 +1089,9 @@ class ThreadAdapter(
// check for anything that doesn't need to handle bind with precision using a custom payload
message.body?.value == oldMessage.message.body?.value &&
message.splitBody == oldMessage.message.splitBody &&
message.shouldHideDivider == oldMessage.message.shouldHideDivider
message.shouldHideDivider == oldMessage.message.shouldHideDivider &&
message.isDraft == oldMessage.message.isDraft &&
message.isScheduledMessage == oldMessage.message.isScheduledMessage
})
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import com.infomaniak.mail.MatomoMail.trackExternalEvent
import com.infomaniak.mail.MatomoMail.trackNewMessageEvent
import com.infomaniak.mail.MatomoMail.trackSendingDraftEvent
import com.infomaniak.mail.R
import com.infomaniak.mail.data.LocalSettings
import com.infomaniak.mail.data.api.ApiRepository
import com.infomaniak.mail.data.cache.RealmDatabase
import com.infomaniak.mail.data.cache.mailboxContent.DraftController
Expand Down Expand Up @@ -158,6 +159,7 @@ class NewMessageViewModel @Inject constructor(
private val sharedUtils: SharedUtils,
private val signatureUtils: SignatureUtils,
private val snackbarManager: SnackbarManager,
private val localSettings: LocalSettings,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
@MainDispatcher private val mainDispatcher: CoroutineDispatcher,
) : AndroidViewModel(application) {
Expand Down Expand Up @@ -1005,6 +1007,8 @@ class NewMessageViewModel @Inject constructor(
cc = ccLiveData.valueOrEmpty().toRealmList()
bcc = bccLiveData.valueOrEmpty().toRealmList()

if (draftAction == DraftAction.SEND) delay = localSettings.cancelDelay

updateDraftAttachmentsWithLiveData(
uiAttachments = attachmentsLiveData.valueOrEmpty(),
step = "executeDraftActionWhenStopping (action = ${draftAction.name}) -> updateDraftFromLiveData",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ class DraftsActionsWorker @AssistedInject constructor(
const val RESULT_USER_ID_KEY = "resultUserIdKey"
const val SCHEDULED_DRAFT_DATE_KEY = "scheduledDraftDateKey"
const val UNSCHEDULE_DRAFT_URL_KEY = "unscheduleDraftUrlKey"
const val CANCEL_RESOURCE_URL_KEY = "cancelResourceUrlKey"
const val IS_SUCCESS = "isSuccess"

const val EMOJI_SENT_STATUS = "emojiSentStatusKey"
Expand Down
Loading
Loading