Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
623c265
refactor: Create compatible methods for use in both summary and futur…
Elouan1411 May 8, 2026
77dc3f4
feat: Add strings and icon
Elouan1411 Apr 30, 2026
1199ba9
feat: Add API route
Elouan1411 Apr 30, 2026
202cba4
fix: Update icon
Elouan1411 Apr 30, 2026
8041d6e
feat: Reuse existing logic to summarize messages for translation
Elouan1411 Apr 30, 2026
09d4915
feat: Add retry button handling
Elouan1411 Apr 30, 2026
07d3666
refactor: Reuse summary method
Elouan1411 May 8, 2026
855f33f
refactor: Adapt bindAiSummary to bindAiAction
Elouan1411 May 8, 2026
8ad0a18
fix: Update UI view information block
Elouan1411 May 11, 2026
f9d7af2
feat: Handle errors during translation
Elouan1411 May 11, 2026
a58c05f
fix: Do not display API result if user clicked closeButton in the mea…
Elouan1411 May 11, 2026
a5a9ebe
feat: Add action when clicking the retry button
Elouan1411 May 11, 2026
e37d825
feat: Add strings for translate feature
Elouan1411 May 11, 2026
2fd96ff
feat: Replace WebView with translated content
Elouan1411 May 12, 2026
e0e20cd
fix: Display summary and translation in separate views
Elouan1411 May 12, 2026
a7367d9
feat: Disable button when translation or summary is already generated
Elouan1411 May 12, 2026
57d7877
refactor: Clean code
Elouan1411 May 12, 2026
ab30b71
refactor: Extract AI state UI logic into InformationBlockView
Elouan1411 May 12, 2026
e494aa3
fix: Manage bottom margin when the button is displayed
Elouan1411 May 12, 2026
99291c7
fix: Display viewInformatuionBlock on app launch if a translation or …
Elouan1411 May 13, 2026
22b0e2b
refactor: Clean code
Elouan1411 May 13, 2026
eb0eea8
fix: Correct matomo key
Elouan1411 May 13, 2026
7343b15
fix: For the translation, use the same function when clicking on the …
Elouan1411 May 13, 2026
1fd8031
fix: Cancel retry timer when user clicks close button
Elouan1411 May 13, 2026
69c6139
fix: Update request to use messageUid and mailBoxUuid instead of content
Elouan1411 May 18, 2026
4f8c1fd
fix: Remove translate and summary buttons from ThreadActionsBottomShe…
Elouan1411 May 18, 2026
a5431c4
fix: Adapt UI of view_information_block to handle all possible cases
Elouan1411 May 29, 2026
a4b2957
fix: Remove duplicate code
Elouan1411 Jun 1, 2026
68b6fef
refactor: Clean code
Elouan1411 Jun 1, 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
1 change: 1 addition & 0 deletions app/src/main/java/com/infomaniak/mail/MatomoMail.kt
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,7 @@ object MatomoMail : Matomo {
SpamSwipe("spamSwipe"),
StrikeThrough("strikeThrough"),
Summary("summary"),
Translate("translate"),
Switch("switch"),
SwitchAccount("switchAccount"),
SwitchColor("switchColor"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,15 @@ object ApiRepository : ApiRepositoryCore() {
)
}

suspend fun aiTranslate(languageCode: String, mailboxUuid: String, msgUid: String): ApiResponse<String> {
return callApi(
url = ApiRoutes.aiTranslate(),
method = POST,
body = mapOf("destination_language" to languageCode, "mailbox_uuid" to mailboxUuid, "msg_uid" to msgUid),
okHttpClient = HttpClient.okHttpClientLongTimeoutWithTokenInterceptor,
)
}

private fun getAiBodyFromMessages(messages: List<AiMessage>) = mapOf("messages" to messages, "output" to "mail")

suspend fun getFeatureFlags(currentMailboxUuid: String): ApiResponse<List<String>> {
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/java/com/infomaniak/mail/data/api/ApiRoutes.kt
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,10 @@ object ApiRoutes {
fun aiSummary(): String {
return "$MAIL_API/api/resume"
}

fun aiTranslate(): String {
return "$MAIL_API/api/translate"
}
//endregion

//region SwissTransfer
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,10 @@ class Body : EmbeddedRealmObject {
@Serializable(FlatteningSubBodiesSerializer::class)
@SerialName("subBody")
var subBodies = realmListOf<SubBody>()
var translatedValue: String? = null
var summary: String? = null
//endregion

val isTranslated: Boolean get() = translatedValue != null
val hasSummary: Boolean get() = summary != null
Comment thread
Elouan1411 marked this conversation as resolved.

companion object
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,7 @@ class MainViewModel @Inject constructor(
emit(openMailbox())
}

private suspend fun openMailbox(): Mailbox? {
suspend fun openMailbox(): Mailbox? {
SentryLog.d(TAG, "Load current mailbox from local")

val mailbox = mailboxController.getMailboxWithFallback(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,6 @@ object PerformSwipeActionManager {
ThreadListFragmentDirections.actionThreadListFragmentToThreadActionsBottomSheetDialog(
threadUid = thread.uid,
shouldLoadDistantResources = false,
isFromThreadList = true,
)
)
true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,6 @@ class ThreadListMultiSelection {
threadUid = selectedThreadsUids.single(),
shouldLoadDistantResources = false,
shouldCloseMultiSelection = true,
isFromThreadList = true,
)
} else {
ThreadListFragmentDirections.actionThreadListFragmentToMultiSelectBottomSheetDialog()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ class PrintMailFragment : Fragment() {
override val isExpandedMap by threadState::isExpandedMap
override val isThemeTheSameMap by threadState::isThemeTheSameMap
override val aiSummaryStateMap by threadState::aiSummaryStateMap
override val aiTranslateStateMap by threadState::aiTranslateStateMap
override val verticalScroll by threadState::verticalScroll
override val isCalendarEventExpandedMap by threadState::isCalendarEventExpandedMap
},
Expand Down
204 changes: 146 additions & 58 deletions app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadAdapter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
*/
package com.infomaniak.mail.ui.main.thread

import android.R.id.closeButton
import android.annotation.SuppressLint
import android.content.Context
import android.net.Uri
Expand Down Expand Up @@ -45,8 +44,6 @@ import com.infomaniak.core.common.utils.FormatData
import com.infomaniak.core.common.utils.format
import com.infomaniak.core.common.utils.formatWithLocal
import com.infomaniak.core.legacy.utils.context
import com.infomaniak.core.ui.view.extension.hideProgressCatching
import com.infomaniak.core.ui.view.extension.showProgressCatching
import com.infomaniak.emojicomponents.data.Reaction
import com.infomaniak.emojicomponents.views.EmojiReactionsView
import com.infomaniak.mail.MatomoMail.MatomoName
Expand All @@ -66,6 +63,7 @@ import com.infomaniak.mail.data.models.message.Message
import com.infomaniak.mail.databinding.ItemMessageBinding
import com.infomaniak.mail.databinding.ItemSuperCollapsedBlockBinding
import com.infomaniak.mail.ui.main.thread.ThreadAdapter.ThreadAdapterViewHolder
import com.infomaniak.mail.ui.main.thread.ThreadFragment.AiAction
import com.infomaniak.mail.ui.main.thread.models.MessageUi
import com.infomaniak.mail.ui.main.thread.models.MessageUi.UnsubscribeState
import com.infomaniak.mail.ui.main.thread.webViewClient.MessageDisplayWebViewClient
Expand Down Expand Up @@ -93,13 +91,15 @@ import com.infomaniak.mail.utils.extensions.indexOfFirstOrNull
import com.infomaniak.mail.utils.extensions.initDisplayWebViewClientAndBridge
import com.infomaniak.mail.utils.extensions.toDate
import com.infomaniak.mail.utils.extensions.toggleChevron
import com.infomaniak.mail.views.InformationBlockView
import io.sentry.Sentry
import io.sentry.SentryLevel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.time.format.FormatStyle
import java.util.Date
import kotlin.context
import androidx.appcompat.R as RAndroid
import com.google.android.material.R as RMaterial

Expand Down Expand Up @@ -196,7 +196,8 @@ class ThreadAdapter(
NotifyType.OnlyRebindCalendarAttendance -> handleCalendarAttendancePayload(item.message)
NotifyType.OnlyRebindEmojiReactions -> handleEmojiReactionPayload(item)
NotifyType.UnsubscribeRebind -> bindUnsubscribe(item)
NotifyType.AiSummaryStateChanged -> holder.bindAiSummary(item.message)
NotifyType.AiSummaryStateChanged -> holder.bindAiAction(item.message, AiAction.SUMMARY)
NotifyType.AiTranslateStateChanged -> holder.bindAiAction(item.message, AiAction.TRANSLATE)
is NotifyType.MessagesCollapseStateChanged -> {
holder.handleMessagesCollapseStatePayload(item.message, isCollapsible = payload.isCollapsible)
}
Expand Down Expand Up @@ -281,7 +282,7 @@ class ThreadAdapter(
initMapForNewMessage(messageUi.message, position)

bindHeader(messageUi.message)
bindAiSummary(messageUi.message)
bindAiAction(messageUi.message)
bindAlerts(messageUi)
bindCalendarEvent(messageUi.message)
bindAttachments(messageUi.message)
Expand Down Expand Up @@ -474,95 +475,180 @@ class ThreadAdapter(
bindRecipientDetails(message, messageDate)
}

private fun MessageViewHolder.bindAiSummary(message: Message) {
val state = threadAdapterState.aiSummaryStateMap[message.uid]
private fun getStateMap(aiAction: AiAction, messageUid: String) = when (aiAction) {
AiAction.SUMMARY -> threadAdapterState.aiSummaryStateMap[messageUid]
AiAction.TRANSLATE -> threadAdapterState.aiTranslateStateMap[messageUid]
}

if (state == null) {
binding.blockInformationView.root.isVisible = false
private fun MessageViewHolder.bindAiAction(message: Message, aiAction: ThreadFragment.AiAction? = null) {
if (aiAction == null) {
bindAiAction(message, AiAction.SUMMARY)
bindAiAction(message, AiAction.TRANSLATE)
return
}

setupBaseVisibility(state)
handleProcessState(state)
setupListeners(message.uid)

val targetView = if (aiAction == AiAction.SUMMARY) {
binding.blockInformationViewSummary
} else {
binding.blockInformationViewTranslate
}

val state = getStateMap(aiAction, message.uid)

val effectiveState = setupBaseVisibility(state, aiAction, targetView, message)
handleProcessState(effectiveState, aiAction, targetView)
setupListeners(message.uid, aiAction, targetView)
}

private fun MessageViewHolder.setupBaseVisibility(state: AiProcessState) {
with(binding.blockInformationView) {
root.isVisible = true
closeButton.isVisible = true
informationTitle.isVisible = true
private fun MessageViewHolder.setupBaseVisibility(
state: AiProcessState?,
aiAction: AiAction,
targetView: InformationBlockView,
message: Message
Comment thread
Elouan1411 marked this conversation as resolved.
): AiProcessState? {
if (state is AiProcessState.Dismissed) {
targetView.isVisible = false
return null
}

val hasSavedState = (aiAction == AiAction.TRANSLATE && message.body?.isTranslated == true) ||
(aiAction == AiAction.SUMMARY && message.body?.hasSummary == true)

val isErrorOrRetrying = state is AiProcessState.Error || state is AiProcessState.Retrying
if ((state == null && !hasSavedState)) {
targetView.isVisible = false
return null
}

val effectiveState =
state ?: AiProcessState.Success(if (aiAction == AiAction.SUMMARY) message.body?.summary ?: "" else "")

with(targetView) {
isVisible = true
isCloseButtonVisible = true

iconAi.isVisible = !isErrorOrRetrying
icon.isVisible = isErrorOrRetrying
informationButton.isVisible = isErrorOrRetrying
informationDescription.isVisible = state is AiProcessState.Success
val isErrorOrRetrying = effectiveState is AiProcessState.Error || effectiveState is AiProcessState.Retrying
val isTranslateSuccess = effectiveState is AiProcessState.Success && aiAction == AiAction.TRANSLATE

isAiIconVisible = !isErrorOrRetrying
isIconVisible = isErrorOrRetrying
isButtonVisible = isErrorOrRetrying || isTranslateSuccess

if (!(effectiveState is AiProcessState.Success && aiAction == AiAction.SUMMARY)) {
description = null
}
}

return effectiveState
}

private fun MessageViewHolder.handleProcessState(state: AiProcessState) {
with(binding.blockInformationView) {
private fun MessageViewHolder.handleProcessState(
state: AiProcessState?,
aiAction: AiAction,
targetView: InformationBlockView
) {
Comment thread
Elouan1411 marked this conversation as resolved.
with(targetView) {
when (state) {
is AiProcessState.Loading -> {
informationTitle.setText(R.string.messageSummaryLoading)
iconAiAnimation.setAnimation(R.raw.euria)
val titleRes = if (aiAction == AiAction.SUMMARY) {
R.string.messageSummaryLoading
} else {
R.string.euriaTranslateMessage
}
title = context.getString(titleRes)
setAnimation(R.raw.euria)
}
is AiProcessState.Success -> {
informationTitle.setText(R.string.messageSummary)
informationDescription.text = state.content
iconAiAnimation.setAnimation(R.raw.euria)
if (aiAction == AiAction.SUMMARY) {
title = context.getString(R.string.messageSummary)
description = state.content
isButtonVisible = false
} else {
title = context.getString(R.string.genericMessageTranslated)
isButtonVisible = true
isButtonEnabled = true
hideButtonProgress(R.string.buttonShowOriginal)
}
setAnimation(R.raw.euria)
}
is AiProcessState.Retrying -> handleRetryingState(state)
is AiProcessState.Error -> handleErrorState(state)
is AiProcessState.Retrying -> handleRetryingState(state, aiAction, targetView)
is AiProcessState.Error -> handleErrorState(state, aiAction, targetView)
is AiProcessState.Dismissed -> Unit
else -> Unit
}
}
}

private fun MessageViewHolder.handleRetryingState(state: AiProcessState.Retrying) {
with(binding.blockInformationView) {
informationTitle.setText(R.string.messageSummaryErrorRetry)
icon.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_warning, 0, 0, 0)
informationButton.isEnabled = false
private fun MessageViewHolder.handleRetryingState(
state: AiProcessState.Retrying,
aiAction: AiAction,
targetView: InformationBlockView
) {
with(targetView) {
Comment thread
Elouan1411 marked this conversation as resolved.
val errorMessageRes = if (aiAction == AiAction.SUMMARY) {
R.string.messageSummaryErrorRetry
} else {
R.string.messageTranslateErrorRetry
}
title = context.getString(errorMessageRes)
setIconRes(R.drawable.ic_warning)
isButtonEnabled = false

if (state.isLoaderVisible) {
informationButton.showProgressCatching(context.getColor(R.color.primaryTextColor))
showButtonProgress(context.getColor(R.color.primaryTextColor))
} else {
informationButton.hideProgressCatching(R.string.aiButtonRetry)
hideButtonProgress(R.string.aiButtonRetry)
}
}
}

private fun MessageViewHolder.handleErrorState(state: AiProcessState.Error) {
with(binding.blockInformationView) {
icon.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_warning, 0, 0, 0)
private fun MessageViewHolder.handleErrorState(
state: AiProcessState.Error,
aiAction: AiAction,
targetView: InformationBlockView
) {
with(targetView) {
Comment thread
Elouan1411 marked this conversation as resolved.
setIconRes(R.drawable.ic_warning)

if (state.canRetry) {
informationTitle.setText(R.string.messageSummaryErrorRetry)
informationButton.isEnabled = true
informationButton.hideProgressCatching(R.string.aiButtonRetry)
val errorMessageRes = if (aiAction == AiAction.SUMMARY) {
R.string.messageSummaryErrorRetry
} else {
R.string.messageTranslateErrorRetry
}
title = this.context.getString(errorMessageRes)
isButtonEnabled = true
hideButtonProgress(R.string.aiButtonRetry)

if (state.isRetry && !state.wasLoaderShown) {
threadAdapterCallbacks?.showSnackbarRetry?.invoke()
threadAdapterCallbacks?.showSnackbarRetry?.invoke(errorMessageRes)
}
} else {
informationTitle.setText(R.string.messageSummaryError)
informationButton.isVisible = false
val errorMessageRes = when {
aiAction == AiAction.SUMMARY -> R.string.messageSummaryError
state.targetSameAsSource -> R.string.translationTargetSameAsSource
else -> R.string.messageTranslateError
}
title = context.getString(errorMessageRes)
isButtonVisible = false
}
}
}

private fun MessageViewHolder.setupListeners(messageUid: String) {
with(binding.blockInformationView) {
closeButton.setOnClickListener {
threadAdapterState.aiSummaryStateMap.remove(messageUid)
root.isVisible = false
threadAdapterCallbacks?.onAiSummaryClose?.invoke(messageUid)
private fun MessageViewHolder.setupListeners(messageUid: String, aiAction: AiAction, targetView: InformationBlockView) {
Comment thread
Elouan1411 marked this conversation as resolved.
with(targetView) {
setOnCloseListener {
threadAdapterCallbacks?.onAiBannerClose?.invoke(messageUid, aiAction)
isVisible = false
}

informationButton.setOnClickListener {
threadAdapterCallbacks?.onAiSummaryRetry?.invoke(messageUid)
setOnActionClicked {
val state = getStateMap(aiAction, messageUid)
if (state is AiProcessState.Success && aiAction == AiAction.TRANSLATE) {
threadAdapterCallbacks?.onShowOriginal?.invoke(messageUid)
} else {
threadAdapterCallbacks?.onAiBannerRetry?.invoke(messageUid, aiAction)
}
}
}
}
Expand Down Expand Up @@ -1077,6 +1163,7 @@ class ThreadAdapter(
data object UnsubscribeRebind : NotifyType
data object UpdatePermissions : NotifyType
data object AiSummaryStateChanged : NotifyType
data object AiTranslateStateChanged : NotifyType
@JvmInline
value class MessagesCollapseStateChanged(val isCollapsible: Boolean) : NotifyType
}
Expand Down Expand Up @@ -1218,9 +1305,10 @@ class ThreadAdapter(
var onAddReaction: ((Message) -> Unit)? = null,
var onAddEmoji: ((emoji: String, messageUid: String) -> Unit)? = null,
var showEmojiDetails: ((messageUid: String, emoji: String) -> Unit)? = null,
var onAiSummaryRetry: ((messageUid: String) -> Unit)? = null,
var onAiSummaryClose: ((messageUid: String) -> Unit)? = null,
var showSnackbarRetry: (() -> Unit)? = null,
var onAiBannerRetry: ((messageUid: String, aiAction: AiAction) -> Unit)? = null,
var showSnackbarRetry: ((errorMessage: Int) -> Unit)? = null,
var onAiBannerClose: ((messageUid: String, aiAction: AiAction) -> Unit)? = null,
var onShowOriginal: ((messageUid: String) -> Unit)? = null,
)

enum class DisplayType(val layout: Int) {
Expand Down
Loading
Loading