diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/SwipeToSelectTouchListener.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/SwipeToSelectTouchListener.kt
new file mode 100644
index 00000000000..385e2a6bc8a
--- /dev/null
+++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/SwipeToSelectTouchListener.kt
@@ -0,0 +1,117 @@
+/*
+ * Infomaniak Mail - Android
+ * Copyright (C) 2026 Infomaniak Network SA
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.infomaniak.mail.ui.main.folder
+
+import android.view.HapticFeedbackConstants
+import android.view.MotionEvent
+import android.view.ViewConfiguration
+import androidx.recyclerview.widget.RecyclerView
+import com.infomaniak.mail.data.models.thread.Thread
+import kotlin.math.abs
+
+class SwipeToSelectTouchListener(
+ recyclerView: RecyclerView,
+ private val multiSelection: MultiSelectionListener,
+) : RecyclerView.OnItemTouchListener {
+
+ private val touchSlop = ViewConfiguration.get(recyclerView.context).scaledTouchSlop
+ private var isActive = false
+ private var startX = 0f
+ private var startY = 0f
+ private var startPosition = RecyclerView.NO_POSITION
+ private var lastSelectedPosition = RecyclerView.NO_POSITION
+ private var selectionIntent: Boolean? = null // true = select, false = deselect
+
+ override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean {
+ when (e.actionMasked) {
+ MotionEvent.ACTION_DOWN -> {
+ startX = e.x
+ startY = e.y
+ startPosition = rv.findChildViewUnder(startX, startY)
+ ?.let(rv::getChildAdapterPosition)
+ ?: RecyclerView.NO_POSITION
+ lastSelectedPosition = RecyclerView.NO_POSITION
+ selectionIntent = null
+ isActive = false
+ }
+ MotionEvent.ACTION_MOVE -> {
+ if (!isActive && multiSelection.isEnabled) {
+ val dy = abs(e.y - startY)
+ val dx = abs(e.x - startX)
+ if (dy > touchSlop && dy > dx) {
+ val startThread = (rv.adapter as? ThreadListAdapter)?.getThreadAt(startPosition)
+ selectionIntent = startThread != null && !multiSelection.selectedItems.contains(startThread)
+
+ if (startThread != null) {
+ applyIntent(startThread)
+ multiSelection.publishSelectedItems()
+ rv.adapter?.notifyItemChanged(startPosition, ThreadListAdapter.NotificationType.SELECTED_STATE)
+ lastSelectedPosition = startPosition
+ }
+
+ isActive = true
+ return true
+ }
+ }
+ }
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> reset()
+ }
+ return isActive
+ }
+
+ override fun onTouchEvent(rv: RecyclerView, e: MotionEvent) {
+ when (e.actionMasked) {
+ MotionEvent.ACTION_MOVE -> {
+ if (!isActive) return
+ val view = rv.findChildViewUnder(e.x, e.y) ?: return
+ val position = rv.getChildAdapterPosition(view)
+ if (position == RecyclerView.NO_POSITION || position == lastSelectedPosition) return
+
+ val thread = (rv.adapter as? ThreadListAdapter)?.getThreadAt(position) ?: return
+ applyIntent(thread)
+ multiSelection.publishSelectedItems()
+ rv.adapter?.notifyItemChanged(position, ThreadListAdapter.NotificationType.SELECTED_STATE)
+ rv.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
+ lastSelectedPosition = position
+ }
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> reset()
+ }
+ }
+
+ override fun onRequestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {
+ if (disallowIntercept) reset()
+ }
+
+ private fun applyIntent(thread: Thread) {
+ val intent = selectionIntent ?: return
+ if (intent) {
+ multiSelection.selectedItems.add(thread)
+ } else {
+ multiSelection.selectedItems.remove(thread)
+ }
+ }
+
+ private fun reset() {
+ isActive = false
+ startX = 0f
+ startY = 0f
+ startPosition = RecyclerView.NO_POSITION
+ lastSelectedPosition = RecyclerView.NO_POSITION
+ selectionIntent = null
+ }
+}
diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListAdapter.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListAdapter.kt
index 441434df1d8..b89f1cca1d8 100644
--- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListAdapter.kt
+++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListAdapter.kt
@@ -181,6 +181,10 @@ class ThreadListAdapter @Inject constructor(
.takeIf { position -> position != -1 }
}
+ fun getThreadAt(position: Int): Thread? {
+ return (dataSet.getOrNull(position) as? ThreadListItem.Content)?.thread
+ }
+
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ThreadListViewHolder {
val layoutInflater = LayoutInflater.from(parent.context)
val binding = when (viewType) {
diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListFragment.kt
index 8061efb4f7e..6ed30d0d909 100644
--- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListFragment.kt
+++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListFragment.kt
@@ -410,6 +410,17 @@ class ThreadListFragment : TwoPaneFragment(), PickerEmojiObserver {
disableDragDirection(DirectionFlag.LEFT)
addStickyDateDecoration(threadListAdapter, localSettings.threadDensity)
}
+
+ binding.threadsList.addOnItemTouchListener(
+ SwipeToSelectTouchListener(
+ recyclerView = binding.threadsList,
+ multiSelection = object : MultiSelectionListener {
+ override var isEnabled by mainViewModel::isMultiSelectOn
+ override val selectedItems by mainViewModel::selectedThreads
+ override val publishSelectedItems = mainViewModel::publishSelectedItems
+ },
+ )
+ )
}
private fun updateDisabledSwipeActionsUi(featureFlags: FeatureFlagSet?, folderRole: FolderRole?) {