diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt
index 72d9fc8a16..5d5aae66bb 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt
@@ -54,6 +54,7 @@ import im.vector.app.features.crypto.verification.SupportedVerificationMethodsPr
 import im.vector.app.features.home.room.detail.RoomDetailAction.VoiceBroadcastAction
 import im.vector.app.features.home.room.detail.error.RoomNotFound
 import im.vector.app.features.home.room.detail.location.RedactLiveLocationShareEventUseCase
+import im.vector.app.features.home.room.detail.poll.VoteToPollUseCase
 import im.vector.app.features.home.room.detail.sticker.StickerPickerActionHandler
 import im.vector.app.features.home.room.detail.timeline.factory.TimelineFactory
 import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlRetriever
@@ -90,7 +91,6 @@ import org.matrix.android.sdk.api.raw.RawService
 import org.matrix.android.sdk.api.session.Session
 import org.matrix.android.sdk.api.session.crypto.MXCryptoError
 import org.matrix.android.sdk.api.session.events.model.EventType
-import org.matrix.android.sdk.api.session.events.model.LocalEcho
 import org.matrix.android.sdk.api.session.events.model.RelationType
 import org.matrix.android.sdk.api.session.events.model.content.WithHeldCode
 import org.matrix.android.sdk.api.session.events.model.isAttachmentMessage
@@ -154,6 +154,7 @@ class TimelineViewModel @AssistedInject constructor(
         timelineFactory: TimelineFactory,
         private val spaceStateHandler: SpaceStateHandler,
         private val voiceBroadcastHelper: VoiceBroadcastHelper,
+        private val voteToPollUseCase: VoteToPollUseCase,
 ) : VectorViewModel<RoomDetailViewState, RoomDetailAction, RoomDetailViewEvents>(initialState),
         Timeline.Listener, ChatEffectManager.Delegate, CallProtocolsChecker.Listener, LocationSharingServiceConnection.Callback {
 
@@ -1235,15 +1236,11 @@ class TimelineViewModel @AssistedInject constructor(
 
     private fun handleVoteToPoll(action: RoomDetailAction.VoteToPoll) {
         if (room == null) return
-        // Do not allow to vote unsent local echo of the poll event
-        if (LocalEcho.isLocalEchoId(action.eventId)) return
-        // Do not allow to vote the same option twice
-        room.getTimelineEvent(action.eventId)?.let { pollTimelineEvent ->
-            val currentVote = pollTimelineEvent.annotations?.pollResponseSummary?.aggregatedContent?.myVote
-            if (currentVote != action.optionKey) {
-                room.sendService().voteToPoll(action.eventId, action.optionKey)
-            }
-        }
+        voteToPollUseCase.execute(
+                roomId = room.roomId,
+                pollEventId = action.eventId,
+                optionId = action.optionKey,
+        )
     }
 
     private fun handleEndPoll(eventId: String) {
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/poll/VoteToPollUseCase.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/poll/VoteToPollUseCase.kt
new file mode 100644
index 0000000000..78c647bc63
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/poll/VoteToPollUseCase.kt
@@ -0,0 +1,51 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package im.vector.app.features.home.room.detail.poll
+
+import im.vector.app.core.di.ActiveSessionHolder
+import org.matrix.android.sdk.api.session.events.model.LocalEcho
+import org.matrix.android.sdk.api.session.room.getTimelineEvent
+import javax.inject.Inject
+
+// TODO add unit tests
+class VoteToPollUseCase @Inject constructor(
+        private val activeSessionHolder: ActiveSessionHolder,
+) {
+
+    fun execute(roomId: String, pollEventId: String, optionId: String) {
+        // Do not allow to vote unsent local echo of the poll event
+        if (LocalEcho.isLocalEchoId(pollEventId)) return
+
+        val room = activeSessionHolder.getActiveSession()
+                .roomService()
+                .getRoom(roomId)
+
+        room?.getTimelineEvent(pollEventId)?.let { pollTimelineEvent ->
+            val currentVote = pollTimelineEvent
+                    .annotations
+                    ?.pollResponseSummary
+                    ?.aggregatedContent
+                    ?.myVote
+            if (currentVote != optionId) {
+                room.sendService().voteToPoll(
+                        pollEventId = pollEventId,
+                        answerId = optionId
+                )
+            }
+        }
+    }
+}
diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollDetailAction.kt b/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollDetailAction.kt
new file mode 100644
index 0000000000..dbf8436399
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollDetailAction.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2022 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package im.vector.app.features.roomprofile.polls.detail.ui
+
+import im.vector.app.core.platform.VectorViewModelAction
+
+sealed interface RoomPollDetailAction : VectorViewModelAction {
+    data class Vote(val pollEventId: String, val optionId: String) : RoomPollDetailAction
+}
diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollDetailController.kt b/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollDetailController.kt
index a4318151be..61ef41b14f 100644
--- a/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollDetailController.kt
+++ b/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollDetailController.kt
@@ -22,7 +22,7 @@ import javax.inject.Inject
 class RoomPollDetailController @Inject constructor() : TypedEpoxyController<RoomPollDetailViewState>() {
 
     interface Callback {
-        fun vote(pollId: String, optionId: String)
+        fun vote(pollEventId: String, optionId: String)
     }
 
     var callback: Callback? = null
diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollDetailFragment.kt b/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollDetailFragment.kt
index 7d27963023..8340c677ef 100644
--- a/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollDetailFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollDetailFragment.kt
@@ -40,7 +40,9 @@ data class RoomPollDetailArgs(
 ) : Parcelable
 
 @AndroidEntryPoint
-class RoomPollDetailFragment : VectorBaseFragment<FragmentRoomPollDetailBinding>() {
+class RoomPollDetailFragment :
+        VectorBaseFragment<FragmentRoomPollDetailBinding>(),
+        RoomPollDetailController.Callback {
 
     @Inject lateinit var roomPollDetailController: RoomPollDetailController
 
@@ -54,17 +56,22 @@ class RoomPollDetailFragment : VectorBaseFragment<FragmentRoomPollDetailBinding>
     override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
         super.onViewCreated(view, savedInstanceState)
         setupToolbar()
+        setupDetailView()
+        // TODO add link to go to timeline message + create a ViewNavigator
+    }
 
+    override fun onDestroyView() {
+        roomPollDetailController.callback = null
+        views.pollDetailRecyclerView.cleanup()
+        super.onDestroyView()
+    }
+
+    private fun setupDetailView() {
+        roomPollDetailController.callback = this
         views.pollDetailRecyclerView.configureWith(
                 roomPollDetailController,
                 hasFixedSize = true,
         )
-        // TODO setup callback in controller for vote action
-    }
-
-    override fun onDestroyView() {
-        views.pollDetailRecyclerView.cleanup()
-        super.onDestroyView()
     }
 
     private fun setupToolbar(isEnded: Boolean? = null) {
@@ -85,4 +92,8 @@ class RoomPollDetailFragment : VectorBaseFragment<FragmentRoomPollDetailBinding>
         setupToolbar(state.pollDetail.isEnded)
         roomPollDetailController.setData(state)
     }
+
+    override fun vote(pollEventId: String, optionId: String) {
+        viewModel.handle(RoomPollDetailAction.Vote(pollEventId = pollEventId, optionId = optionId))
+    }
 }
diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollDetailItem.kt b/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollDetailItem.kt
index 9a80c32640..4420776a47 100644
--- a/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollDetailItem.kt
+++ b/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollDetailItem.kt
@@ -72,7 +72,7 @@ abstract class RoomPollDetailItem : VectorEpoxyModel<RoomPollDetailItem.Holder>(
         val relatedEventId = eventId
 
         if (canVote && relatedEventId != null) {
-            callback?.vote(pollId = relatedEventId, optionId = optionViewState.optionId)
+            callback?.vote(pollEventId = relatedEventId, optionId = optionViewState.optionId)
         }
     }
 
diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollDetailViewModel.kt b/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollDetailViewModel.kt
index e3b7631cce..d76d0f7279 100644
--- a/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollDetailViewModel.kt
+++ b/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollDetailViewModel.kt
@@ -23,19 +23,19 @@ import dagger.assisted.AssistedInject
 import im.vector.app.core.di.MavericksAssistedViewModelFactory
 import im.vector.app.core.di.hiltMavericksViewModelFactory
 import im.vector.app.core.event.GetTimelineEventUseCase
-import im.vector.app.core.platform.EmptyAction
 import im.vector.app.core.platform.VectorViewModel
-import im.vector.app.features.auth.ReAuthState
-import im.vector.app.features.auth.ReAuthViewModel
+import im.vector.app.features.home.room.detail.poll.VoteToPollUseCase
 import kotlinx.coroutines.flow.launchIn
 import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.onEach
 
+// TODO add unit tests
 class RoomPollDetailViewModel @AssistedInject constructor(
         @Assisted initialState: RoomPollDetailViewState,
         private val getTimelineEventUseCase: GetTimelineEventUseCase,
         private val roomPollDetailMapper: RoomPollDetailMapper,
-) : VectorViewModel<RoomPollDetailViewState, EmptyAction, RoomPollDetailViewEvent>(initialState) {
+        private val voteToPollUseCase: VoteToPollUseCase,
+) : VectorViewModel<RoomPollDetailViewState, RoomPollDetailAction, RoomPollDetailViewEvent>(initialState) {
 
     @AssistedFactory
     interface Factory : MavericksAssistedViewModelFactory<RoomPollDetailViewModel, RoomPollDetailViewState> {
@@ -58,7 +58,17 @@ class RoomPollDetailViewModel @AssistedInject constructor(
                 .launchIn(viewModelScope)
     }
 
-    override fun handle(action: EmptyAction) {
-        // do nothing for now
+    override fun handle(action: RoomPollDetailAction) {
+        when (action) {
+            is RoomPollDetailAction.Vote -> handleVote(action)
+        }
+    }
+
+    private fun handleVote(vote: RoomPollDetailAction.Vote) = withState { state ->
+        voteToPollUseCase.execute(
+                roomId = state.roomId,
+                pollEventId = vote.pollEventId,
+                optionId = vote.optionId,
+        )
     }
 }