diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index a2e9858d9f..00728efc1e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -30,14 +30,14 @@ import androidx.compose.runtime.saveable.rememberSaveable import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import im.vector.app.features.analytics.plan.PollEnd -import im.vector.app.features.analytics.plan.PollVote import io.element.android.features.messages.impl.MessagesNavigator import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory import io.element.android.features.messages.impl.timeline.model.NewEventState import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.session.SessionState import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager +import io.element.android.features.poll.api.actions.EndPollAction +import io.element.android.features.poll.api.actions.SendPollResponseAction import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.featureflag.api.FeatureFlagService @@ -52,7 +52,6 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemE import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus import io.element.android.libraries.matrix.ui.room.canSendMessageAsState -import io.element.android.services.analytics.api.AnalyticsService import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.launchIn @@ -69,11 +68,12 @@ class TimelinePresenter @AssistedInject constructor( private val dispatchers: CoroutineDispatchers, private val appScope: CoroutineScope, @Assisted private val navigator: MessagesNavigator, - private val analyticsService: AnalyticsService, private val verificationService: SessionVerificationService, private val encryptionService: EncryptionService, private val featureFlagService: FeatureFlagService, private val redactedVoiceMessageManager: RedactedVoiceMessageManager, + private val sendPollResponseAction: SendPollResponseAction, + private val endPollAction: EndPollAction, ) : Presenter { @AssistedFactory @@ -132,18 +132,15 @@ class TimelinePresenter @AssistedInject constructor( ) } is TimelineEvents.PollAnswerSelected -> appScope.launch { - room.sendPollResponse( + sendPollResponseAction.execute( pollStartId = event.pollStartId, - answers = listOf(event.answerId), + answerId = event.answerId ) - analyticsService.capture(PollVote()) } is TimelineEvents.PollEndClicked -> appScope.launch { - room.endPoll( + endPollAction.execute( pollStartId = event.pollStartId, - text = "The poll with event id: ${event.pollStartId} has ended." ) - analyticsService.capture(PollEnd()) } is TimelineEvents.PollEditClicked -> navigator.onEditPollClicked(event.pollStartId) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt index 0c5b33747b..7a35d565ef 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -517,12 +517,10 @@ private fun MessageEventBubbleContent( ) { TimelineItemEventContentView( content = event.content, - isMine = event.isMine, - isEditable = event.isEditable, interactionSource = interactionSource, + extraPadding = event.toExtraPadding(), onClick = onMessageClick, onLongClick = onMessageLongClick, - extraPadding = event.toExtraPadding(), eventSink = eventSink, modifier = contentModifier, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt index fbf39125b3..4a48605355 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt @@ -80,12 +80,10 @@ fun TimelineItemStateEventRow( ) { TimelineItemEventContentView( content = event.content, - isMine = event.isMine, - isEditable = event.isEditable, interactionSource = interactionSource, + extraPadding = noExtraPadding, onClick = onClick, onLongClick = onLongClick, - extraPadding = noExtraPadding, eventSink = eventSink, modifier = Modifier.defaultTimelineContentPadding() ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt index 77537b9565..2e243bea6d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt @@ -41,8 +41,6 @@ import io.element.android.libraries.architecture.Presenter @Composable fun TimelineItemEventContentView( content: TimelineItemEventContent, - isMine: Boolean, - isEditable: Boolean, interactionSource: MutableInteractionSource, extraPadding: ExtraPadding, onClick: () -> Unit, @@ -103,8 +101,6 @@ fun TimelineItemEventContentView( ) is TimelineItemPollContent -> TimelineItemPollView( content = content, - isMine = isMine, - isEditable = isEditable, eventSink = eventSink, modifier = modifier, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollView.kt index c334b2cc56..4754bf6298 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollView.kt @@ -22,7 +22,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import io.element.android.features.messages.impl.timeline.TimelineEvents import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContentProvider -import io.element.android.features.poll.api.PollContentView +import io.element.android.features.poll.api.pollcontent.PollContentView import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.matrix.api.core.EventId @@ -31,8 +31,6 @@ import kotlinx.collections.immutable.toImmutableList @Composable fun TimelineItemPollView( content: TimelineItemPollContent, - isMine: Boolean, - isEditable: Boolean, eventSink: (TimelineEvents) -> Unit, modifier: Modifier = Modifier, ) { @@ -54,8 +52,8 @@ fun TimelineItemPollView( answerItems = content.answerItems.toImmutableList(), pollKind = content.pollKind, isPollEnded = content.isEnded, - isPollEditable = isEditable, - isMine = isMine, + isPollEditable = content.isEditable, + isMine = content.isMine, onAnswerSelected = ::onAnswerSelected, onPollEdit = ::onPollEdit, onPollEnd = ::onPollEnd, @@ -69,8 +67,6 @@ internal fun TimelineItemPollViewPreview(@PreviewParameter(TimelineItemPollConte ElementPreview { TimelineItemPollView( content = content, - isMine = false, - isEditable = false, eventSink = {}, ) } @@ -81,8 +77,6 @@ internal fun TimelineItemPollCreatorViewPreview(@PreviewParameter(TimelineItemPo ElementPreview { TimelineItemPollView( content = content, - isMine = true, - isEditable = false, eventSink = {}, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt index b5a3c9545e..5e72392914 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt @@ -59,7 +59,7 @@ class TimelineItemContentFactory @Inject constructor( is RoomMembershipContent -> roomMembershipFactory.create(eventTimelineItem) is StateContent -> stateFactory.create(eventTimelineItem) is StickerContent -> stickerFactory.create(itemContent) - is PollContent -> pollFactory.create(itemContent, eventTimelineItem.eventId) + is PollContent -> pollFactory.create(eventTimelineItem, itemContent) is UnableToDecryptContent -> utdFactory.create(itemContent) is UnknownContent -> TimelineItemUnknownContent } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentPollFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentPollFactory.kt index 04551f7086..d2176aa887 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentPollFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentPollFactory.kt @@ -19,63 +19,32 @@ package io.element.android.features.messages.impl.timeline.factories.event import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent -import io.element.android.features.poll.api.PollAnswerItem +import io.element.android.features.poll.api.pollcontent.PollContentStateFactory import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags -import io.element.android.libraries.matrix.api.MatrixClient -import io.element.android.libraries.matrix.api.core.EventId -import io.element.android.libraries.matrix.api.poll.isDisclosed +import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem import io.element.android.libraries.matrix.api.timeline.item.event.PollContent import javax.inject.Inject class TimelineItemContentPollFactory @Inject constructor( - private val matrixClient: MatrixClient, private val featureFlagService: FeatureFlagService, + private val pollContentStateFactory: PollContentStateFactory, ) { suspend fun create( + event: EventTimelineItem, content: PollContent, - eventId: EventId? ): TimelineItemEventContent { if (!featureFlagService.isFeatureEnabled(FeatureFlags.Polls)) return TimelineItemUnknownContent - - // Todo Move this computation to the matrix rust sdk - val totalVoteCount = content.votes.flatMap { it.value }.size - val myVotes = content.votes.filter { matrixClient.sessionId in it.value }.keys - val isEndedPoll = content.endTime != null - val winnerIds = if (!isEndedPoll) { - emptyList() - } else { - content.answers - .map { answer -> answer.id } - .groupBy { answerId -> content.votes[answerId]?.size ?: 0 } // Group by votes count - .maxByOrNull { (votes, _) -> votes } // Keep max voted answers - ?.takeIf { (votes, _) -> votes > 0 } // Ignore if no option has been voted - ?.value - .orEmpty() - } - val answerItems = content.answers.map { answer -> - val answerVoteCount = content.votes[answer.id]?.size ?: 0 - val isSelected = answer.id in myVotes - val isWinner = answer.id in winnerIds - val percentage = if (totalVoteCount > 0) answerVoteCount.toFloat() / totalVoteCount.toFloat() else 0f - PollAnswerItem( - answer = answer, - isSelected = isSelected, - isEnabled = !isEndedPoll, - isWinner = isWinner, - isDisclosed = content.kind.isDisclosed || isEndedPoll, - votesCount = answerVoteCount, - percentage = percentage, - ) - } - + val pollContentState = pollContentStateFactory.create(event, content) return TimelineItemPollContent( - eventId = eventId, - question = content.question, - answerItems = answerItems, - pollKind = content.kind, - isEnded = isEndedPoll, + isMine = pollContentState.isMine, + isEditable = pollContentState.isPollEditable, + eventId = event.eventId, + question = pollContentState.question, + answerItems = pollContentState.answerItems, + pollKind = pollContentState.pollKind, + isEnded = pollContentState.isPollEnded, ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContent.kt index dc47c12a86..9ce94b72d0 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContent.kt @@ -16,11 +16,13 @@ package io.element.android.features.messages.impl.timeline.model.event -import io.element.android.features.poll.api.PollAnswerItem +import io.element.android.features.poll.api.pollcontent.PollAnswerItem import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.poll.PollKind data class TimelineItemPollContent( + val isMine: Boolean, + val isEditable: Boolean, val eventId: EventId?, val question: String, val answerItems: List, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContentProvider.kt index d20f9fa856..3a007f31e5 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContentProvider.kt @@ -17,9 +17,9 @@ package io.element.android.features.messages.impl.timeline.model.event import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import io.element.android.features.poll.api.PollAnswerItem -import io.element.android.features.poll.api.aPollAnswerItemList -import io.element.android.features.poll.api.aPollQuestion +import io.element.android.features.poll.api.pollcontent.aPollAnswerItemList +import io.element.android.features.poll.api.pollcontent.aPollQuestion +import io.element.android.features.poll.api.pollcontent.PollAnswerItem import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.poll.PollKind @@ -28,12 +28,16 @@ open class TimelineItemPollContentProvider : PreviewParameterProvider = aPollAnswerItemList(), + isMine: Boolean = false, + isEditable: Boolean = false, isEnded: Boolean = false, ): TimelineItemPollContent { return TimelineItemPollContent( @@ -41,6 +45,8 @@ fun aTimelineItemPollContent( pollKind = PollKind.Disclosed, question = question, answerItems = answerItems, + isMine = isMine, + isEditable = isEditable, isEnded = isEnded, ) } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt index c2a8cd40d2..d0a7484741 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt @@ -29,7 +29,7 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent -import io.element.android.features.poll.api.aPollAnswerItemList +import io.element.android.features.poll.api.pollcontent.aPollAnswerItemList import io.element.android.libraries.featureflag.test.InMemoryPreferencesStore import io.element.android.libraries.matrix.test.A_MESSAGE import io.element.android.tests.testutils.WarmUpRule diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentPollFactoryTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentPollFactoryTest.kt index 2ae88288ae..144c70f55d 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentPollFactoryTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentPollFactoryTest.kt @@ -18,7 +18,7 @@ package io.element.android.features.messages.impl.timeline.factories.event import com.google.common.truth.Truth import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent -import io.element.android.features.poll.api.PollAnswerItem +import io.element.android.features.poll.api.pollcontent.PollAnswerItem import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.core.EventId @@ -55,14 +55,14 @@ internal class TimelineItemContentPollFactoryTest { @Test fun `Disclosed poll - not ended, no votes`() = runTest { - Truth.assertThat(factory.create(aPollContent(), eventId = null)).isEqualTo(aTimelineItemPollContent()) + Truth.assertThat(factory.create(aPollContent(), eventId = null, eventTimelineItem.isOwn)).isEqualTo(aTimelineItemPollContent()) } @Test fun `Disclosed poll - not ended, some votes, including one from current user`() = runTest { val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }.toImmutableMap() Truth.assertThat( - factory.create(aPollContent(votes = votes), eventId = null) + factory.create(aPollContent(votes = votes), eventId = null, eventTimelineItem.isOwn) ) .isEqualTo( aTimelineItemPollContent( @@ -79,7 +79,7 @@ internal class TimelineItemContentPollFactoryTest { @Test fun `Disclosed poll - ended, no votes, no winner`() = runTest { Truth.assertThat( - factory.create(aPollContent(endTime = 1UL), eventId = null) + factory.create(aPollContent(endTime = 1UL), eventId = null, eventTimelineItem.isOwn) ).isEqualTo( aTimelineItemPollContent().let { it.copy( @@ -94,7 +94,11 @@ internal class TimelineItemContentPollFactoryTest { fun `Disclosed poll - ended, some votes, including one from current user (winner)`() = runTest { val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }.toImmutableMap() Truth.assertThat( - factory.create(aPollContent(votes = votes, endTime = 1UL), eventId = null) + factory.create( + aPollContent(votes = votes, endTime = 1UL), + eventId = null, + eventTimelineItem.isOwn + ) ) .isEqualTo( aTimelineItemPollContent( @@ -113,7 +117,11 @@ internal class TimelineItemContentPollFactoryTest { fun `Disclosed poll - ended, some votes, including one from current user (not winner) and two winning votes`() = runTest { val votes = OTHER_WINNING_VOTES.mapKeys { it.key.id }.toImmutableMap() Truth.assertThat( - factory.create(aPollContent(votes = votes, endTime = 1UL), eventId = null) + factory.create( + aPollContent(votes = votes, endTime = 1UL), + eventId = null, + eventTimelineItem.isOwn + ) ) .isEqualTo( aTimelineItemPollContent( @@ -131,7 +139,11 @@ internal class TimelineItemContentPollFactoryTest { @Test fun `Undisclosed poll - not ended, no votes`() = runTest { Truth.assertThat( - factory.create(aPollContent(PollKind.Undisclosed).copy(), eventId = null) + factory.create( + aPollContent(PollKind.Undisclosed).copy(), + eventId = null, + eventTimelineItem.isOwn + ) ).isEqualTo( aTimelineItemPollContent(pollKind = PollKind.Undisclosed).let { it.copy(answerItems = it.answerItems.map { answerItem -> answerItem.copy(isDisclosed = false) }) @@ -143,7 +155,11 @@ internal class TimelineItemContentPollFactoryTest { fun `Undisclosed poll - not ended, some votes, including one from current user`() = runTest { val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }.toImmutableMap() Truth.assertThat( - factory.create(aPollContent(pollKind = PollKind.Undisclosed, votes = votes), eventId = null) + factory.create( + aPollContent(pollKind = PollKind.Undisclosed, votes = votes), + eventId = null, + eventTimelineItem.isOwn + ) ) .isEqualTo( aTimelineItemPollContent( @@ -161,7 +177,11 @@ internal class TimelineItemContentPollFactoryTest { @Test fun `Undisclosed poll - ended, no votes, no winner`() = runTest { Truth.assertThat( - factory.create(aPollContent(pollKind = PollKind.Undisclosed, endTime = 1UL), eventId = null) + factory.create( + aPollContent(pollKind = PollKind.Undisclosed, endTime = 1UL), + eventId = null, + eventTimelineItem.isOwn + ) ).isEqualTo( aTimelineItemPollContent().let { it.copy( @@ -179,7 +199,11 @@ internal class TimelineItemContentPollFactoryTest { fun `Undisclosed poll - ended, some votes, including one from current user (winner)`() = runTest { val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }.toImmutableMap() Truth.assertThat( - factory.create(aPollContent(pollKind = PollKind.Undisclosed, votes = votes, endTime = 1UL), eventId = null) + factory.create( + aPollContent(pollKind = PollKind.Undisclosed, votes = votes, endTime = 1UL), + eventId = null, + eventTimelineItem.isOwn + ) ) .isEqualTo( aTimelineItemPollContent( @@ -199,7 +223,11 @@ internal class TimelineItemContentPollFactoryTest { fun `Undisclosed poll - ended, some votes, including one from current user (not winner) and two winning votes`() = runTest { val votes = OTHER_WINNING_VOTES.mapKeys { it.key.id }.toImmutableMap() Truth.assertThat( - factory.create(aPollContent(PollKind.Undisclosed).copy(votes = votes, endTime = 1UL), eventId = null) + factory.create( + aPollContent(PollKind.Undisclosed).copy(votes = votes, endTime = 1UL), + eventId = null, + eventTimelineItem.isOwn + ) ) .isEqualTo( aTimelineItemPollContent( @@ -217,10 +245,14 @@ internal class TimelineItemContentPollFactoryTest { @Test fun `eventId is populated`() = runTest { - Truth.assertThat(factory.create(aPollContent(), eventId = null)) + Truth.assertThat(factory.create(aPollContent(), eventId = null, eventTimelineItem.isOwn)) .isEqualTo(aTimelineItemPollContent(eventId = null)) - Truth.assertThat(factory.create(aPollContent(), eventId = AN_EVENT_ID)) + Truth.assertThat(factory.create( + aPollContent(), + eventId = AN_EVENT_ID, + eventTimelineItem.isOwn + )) .isEqualTo(aTimelineItemPollContent(eventId = AN_EVENT_ID)) } diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/actions/EndPollAction.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/actions/EndPollAction.kt new file mode 100644 index 0000000000..22982dce97 --- /dev/null +++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/actions/EndPollAction.kt @@ -0,0 +1,23 @@ +/* + * 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 io.element.android.features.poll.api.actions + +import io.element.android.libraries.matrix.api.core.EventId + +interface EndPollAction { + suspend fun execute(pollStartId: EventId): Result +} diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/actions/SendPollResponseAction.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/actions/SendPollResponseAction.kt new file mode 100644 index 0000000000..71cf476f59 --- /dev/null +++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/actions/SendPollResponseAction.kt @@ -0,0 +1,23 @@ +/* + * 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 io.element.android.features.poll.api.actions + +import io.element.android.libraries.matrix.api.core.EventId + +interface SendPollResponseAction { + suspend fun execute(pollStartId: EventId, answerId: String): Result +} diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/PollAnswerItem.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollAnswerItem.kt similarity index 96% rename from features/poll/api/src/main/kotlin/io/element/android/features/poll/api/PollAnswerItem.kt rename to features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollAnswerItem.kt index 1955701c5b..56dbaeb3ca 100644 --- a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/PollAnswerItem.kt +++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollAnswerItem.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.features.poll.api +package io.element.android.features.poll.api.pollcontent import io.element.android.libraries.matrix.api.poll.PollAnswer diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/PollAnswerView.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollAnswerView.kt similarity index 99% rename from features/poll/api/src/main/kotlin/io/element/android/features/poll/api/PollAnswerView.kt rename to features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollAnswerView.kt index 9e9ec624e0..a84ef502ca 100644 --- a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/PollAnswerView.kt +++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollAnswerView.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.features.poll.api +package io.element.android.features.poll.api.pollcontent import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/PollAnswerViewProvider.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollAnswerViewProvider.kt similarity index 97% rename from features/poll/api/src/main/kotlin/io/element/android/features/poll/api/PollAnswerViewProvider.kt rename to features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollAnswerViewProvider.kt index a4a8f2e156..206acf02c6 100644 --- a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/PollAnswerViewProvider.kt +++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollAnswerViewProvider.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.features.poll.api +package io.element.android.features.poll.api.pollcontent import io.element.android.libraries.matrix.api.poll.PollAnswer import kotlinx.collections.immutable.persistentListOf diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentState.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentState.kt new file mode 100644 index 0000000000..0051f7ebd3 --- /dev/null +++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentState.kt @@ -0,0 +1,31 @@ +/* + * 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 io.element.android.features.poll.api.pollcontent + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.poll.PollKind +import kotlinx.collections.immutable.ImmutableList + +data class PollContentState( + val eventId: EventId?, + val question: String, + val answerItems: ImmutableList, + val pollKind: PollKind, + val isPollEditable: Boolean, + val isPollEnded: Boolean, + val isMine: Boolean, +) diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentStateFactory.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentStateFactory.kt new file mode 100644 index 0000000000..7178a6c3ee --- /dev/null +++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentStateFactory.kt @@ -0,0 +1,24 @@ +/* + * 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 io.element.android.features.poll.api.pollcontent + +import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.event.PollContent + +interface PollContentStateFactory { + suspend fun create(event: EventTimelineItem, content: PollContent): PollContentState +} diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/PollContentView.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentView.kt similarity index 93% rename from features/poll/api/src/main/kotlin/io/element/android/features/poll/api/PollContentView.kt rename to features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentView.kt index effaadb07e..706fa6e402 100644 --- a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/PollContentView.kt +++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentView.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.features.poll.api +package io.element.android.features.poll.api.pollcontent import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -49,6 +49,29 @@ import io.element.android.libraries.matrix.api.poll.PollKind import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableList +@Composable +fun PollContentView( + state: PollContentState, + onAnswerSelected: (pollStartId: EventId, answerId: String) -> Unit, + onPollEdit: (pollStartId: EventId) -> Unit, + onPollEnd: (pollStartId: EventId) -> Unit, + modifier: Modifier = Modifier, +) { + PollContentView( + eventId = state.eventId, + question = state.question, + answerItems = state.answerItems, + pollKind = state.pollKind, + isPollEditable = state.isPollEditable, + isPollEnded = state.isPollEnded, + isMine = state.isMine, + onPollEdit = onPollEdit, + onAnswerSelected = onAnswerSelected, + onPollEnd = onPollEnd, + modifier = modifier, + ) +} + @Composable fun PollContentView( eventId: EventId?, diff --git a/features/poll/impl/build.gradle.kts b/features/poll/impl/build.gradle.kts index 5ff9025aae..db91377660 100644 --- a/features/poll/impl/build.gradle.kts +++ b/features/poll/impl/build.gradle.kts @@ -40,6 +40,7 @@ dependencies { implementation(projects.libraries.designsystem) implementation(projects.services.analytics.api) implementation(projects.features.messages.api) + implementation(projects.libraries.dateformatter.api) implementation(projects.libraries.uiStrings) testImplementation(libs.test.junit) diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/actions/DefaultEndPollAction.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/actions/DefaultEndPollAction.kt new file mode 100644 index 0000000000..9959f1aecc --- /dev/null +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/actions/DefaultEndPollAction.kt @@ -0,0 +1,42 @@ +/* + * 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 io.element.android.features.poll.impl.actions + +import com.squareup.anvil.annotations.ContributesBinding +import im.vector.app.features.analytics.plan.PollEnd +import io.element.android.features.poll.api.actions.EndPollAction +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.services.analytics.api.AnalyticsService +import javax.inject.Inject + +@ContributesBinding(RoomScope::class) +class DefaultEndPollAction @Inject constructor( + private val room: MatrixRoom, + private val analyticsService: AnalyticsService, +) : EndPollAction { + + override suspend fun execute(pollStartId: EventId): Result { + return room.endPoll( + pollStartId = pollStartId, + text = "The poll with event id: $pollStartId has ended." + ).onSuccess { + analyticsService.capture(PollEnd()) + } + } +} diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/actions/DefaultSendPollResponseAction.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/actions/DefaultSendPollResponseAction.kt new file mode 100644 index 0000000000..d6688ecb27 --- /dev/null +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/actions/DefaultSendPollResponseAction.kt @@ -0,0 +1,43 @@ +/* + * 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 io.element.android.features.poll.impl.actions + +import com.squareup.anvil.annotations.ContributesBinding +import im.vector.app.features.analytics.plan.PollVote +import io.element.android.features.poll.api.actions.SendPollResponseAction +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.services.analytics.api.AnalyticsService +import javax.inject.Inject + +@ContributesBinding(RoomScope::class) +class DefaultSendPollResponseAction @Inject constructor( + private val room: MatrixRoom, + private val analyticsService: AnalyticsService, +) : SendPollResponseAction { + + override suspend fun execute(pollStartId: EventId, answerId: String): Result { + return room.sendPollResponse( + pollStartId = pollStartId, + answers = listOf(answerId), + ).onSuccess { + analyticsService.capture(PollVote()) + } + } +} + diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryEvents.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryEvents.kt index 1eb676b961..e13f9020b7 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryEvents.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryEvents.kt @@ -16,6 +16,11 @@ package io.element.android.features.poll.impl.history +import io.element.android.libraries.matrix.api.core.EventId + sealed interface PollHistoryEvents { - data object History : PollHistoryEvents + data object LoadMore : PollHistoryEvents + data class PollAnswerSelected(val pollStartId: EventId, val answerId: String) : PollHistoryEvents + data class PollEndClicked(val pollStartId: EventId) : PollHistoryEvents + data object EditPoll : PollHistoryEvents } diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryItem.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryItem.kt new file mode 100644 index 0000000000..5b12dc8b2c --- /dev/null +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryItem.kt @@ -0,0 +1,23 @@ +/* + * 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 io.element.android.features.poll.impl.history + +import io.element.android.features.poll.api.pollcontent.PollContentState + +sealed interface PollHistoryItem { + data class PollContent(val formattedDate: String, val state: PollContentState) : PollHistoryItem +} diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryItemsFactory.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryItemsFactory.kt new file mode 100644 index 0000000000..1d09e115ae --- /dev/null +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryItemsFactory.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 io.element.android.features.poll.impl.history + +import io.element.android.features.poll.api.pollcontent.PollContentStateFactory +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.dateformatter.api.DaySeparatorFormatter +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.event.PollContent +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class PollHistoryItemsFactory @Inject constructor( + private val pollContentStateFactory: PollContentStateFactory, + private val daySeparatorFormatter: DaySeparatorFormatter, + private val dispatchers: CoroutineDispatchers, +) { + + suspend fun create(timelineItems: List): List = withContext(dispatchers.computation) { + timelineItems.mapNotNull { create(it) }.reversed() + } + + private suspend fun create(timelineItem: MatrixTimelineItem): PollHistoryItem? { + return when (timelineItem) { + is MatrixTimelineItem.Event -> { + val pollContent = timelineItem.event.content as? PollContent ?: return null + val pollContentState = pollContentStateFactory.create(timelineItem.event, pollContent) + PollHistoryItem.PollContent( + formattedDate = daySeparatorFormatter.format(timelineItem.event.timestamp), + state = pollContentState + ) + } + else -> null + } + } +} + diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt index 1025b2184d..40db94306b 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt @@ -17,22 +17,29 @@ package io.element.android.features.poll.impl.history import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import io.element.android.features.poll.api.actions.EndPollAction +import io.element.android.features.poll.api.actions.SendPollResponseAction import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.matrix.api.timeline.MatrixTimeline -import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem -import io.element.android.libraries.matrix.api.timeline.item.event.PollContent import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch class PollHistoryPresenter @AssistedInject constructor( @Assisted private val pollHistory: MatrixTimeline, + private val appCoroutineScope: CoroutineScope, + private val sendPollResponseAction: SendPollResponseAction, + private val endPollAction: EndPollAction, + private val pollHistoryItemFactory: PollHistoryItemsFactory, ) : Presenter { @AssistedFactory @@ -47,26 +54,36 @@ class PollHistoryPresenter @AssistedInject constructor( val paginationState by pollHistory.paginationState.collectAsState() val timelineItemsFlow = remember { pollHistory.timelineItems.map { items -> - items.filterIsInstance() - .map { it.event.content } - .filterIsInstance() - .reversed() - }.onEach { - if (it.isEmpty()) pollHistory.paginateBackwards(20, 50) + pollHistoryItemFactory.create(items) } } val items by timelineItemsFlow.collectAsState(initial = emptyList()) - + LaunchedEffect(items.size) { + if (items.isEmpty()) loadMore() + } + val coroutineScope = rememberCoroutineScope() fun handleEvents(event: PollHistoryEvents) { when (event) { - is PollHistoryEvents.History -> Unit // TODO which events to handle? + is PollHistoryEvents.LoadMore -> { + coroutineScope.loadMore() + } + is PollHistoryEvents.PollAnswerSelected -> appCoroutineScope.launch { + sendPollResponseAction.execute(pollStartId = event.pollStartId, answerId = event.answerId) + } + is PollHistoryEvents.PollEndClicked -> appCoroutineScope.launch { + endPollAction.execute(pollStartId = event.pollStartId) + } + PollHistoryEvents.EditPoll -> Unit } } - return PollHistoryState( paginationState = paginationState, - matrixTimelineItems = items.toImmutableList(), + pollItems = items.toImmutableList(), eventSink = ::handleEvents, ) } + + private fun CoroutineScope.loadMore() = launch { + pollHistory.paginateBackwards(20, 3) + } } diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryState.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryState.kt index 4354736aba..1c636060ec 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryState.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryState.kt @@ -17,11 +17,10 @@ package io.element.android.features.poll.impl.history import io.element.android.libraries.matrix.api.timeline.MatrixTimeline -import io.element.android.libraries.matrix.api.timeline.item.event.PollContent import kotlinx.collections.immutable.ImmutableList data class PollHistoryState( val paginationState: MatrixTimeline.PaginationState, - val matrixTimelineItems: ImmutableList, + val pollItems: ImmutableList, val eventSink: (PollHistoryEvents) -> Unit, ) diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryView.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryView.kt index 93a3e3c90c..34dee34a59 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryView.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryView.kt @@ -16,27 +16,31 @@ package io.element.android.features.poll.impl.history +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme -import io.element.android.features.poll.api.PollAnswerItem -import io.element.android.features.poll.api.PollContentView +import io.element.android.features.poll.api.pollcontent.PollContentView import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.aliasScreenTitle import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.HorizontalDivider import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TopAppBar -import io.element.android.libraries.matrix.api.poll.PollAnswer -import kotlinx.collections.immutable.toImmutableList +import io.element.android.libraries.matrix.api.core.EventId @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -70,37 +74,75 @@ fun PollHistoryView( if (state.paginationState.isBackPaginating) item { CircularProgressIndicator() } - items(state.matrixTimelineItems) { pollContent -> - PollContentView( - eventId = null, - question = pollContent.question, - answerItems = pollContent.answers.map { - PollAnswerItem( - answer = PollAnswer( - id = it.id, - text = it.text, - ), - isSelected = false, - isEnabled = false, - isWinner = false, - isDisclosed = false, - votesCount = 9393, - percentage = 4.5f, - ) - }.toImmutableList(), - pollKind = pollContent.kind, - isPollEditable = false, - isPollEnded = false, - isMine = false, - onAnswerSelected = { _, _ -> }, - onPollEdit = {}, - onPollEnd = {}, + itemsIndexed(state.pollItems) { index, pollHistoryItem -> + PollHistoryItemRow( + pollHistoryItem = pollHistoryItem, + onAnswerSelected = fun(pollStartId: EventId, answerId: String) { + state.eventSink(PollHistoryEvents.PollAnswerSelected(pollStartId, answerId)) + }, + onPollEdit = { + state.eventSink(PollHistoryEvents.EditPoll) + }, + onPollEnd = { + state.eventSink(PollHistoryEvents.PollEndClicked(it)) + }, ) + if (index != state.pollItems.lastIndex) { + HorizontalDivider() + } } } } } +@Composable +private fun PollHistoryItemRow( + pollHistoryItem: PollHistoryItem, + onAnswerSelected: (pollStartId: EventId, answerId: String) -> Unit, + onPollEdit: (pollStartId: EventId) -> Unit, + onPollEnd: (pollStartId: EventId) -> Unit, + modifier: Modifier = Modifier, +) { + when (pollHistoryItem) { + is PollHistoryItem.PollContent -> { + PollContentItemRow( + pollContentItem = pollHistoryItem, + onAnswerSelected = onAnswerSelected, + onPollEdit = onPollEdit, + onPollEnd = onPollEnd, + modifier = modifier.padding( + horizontal = 16.dp, + vertical = 24.dp + ), + ) + } + } +} + +@Composable +private fun PollContentItemRow( + pollContentItem: PollHistoryItem.PollContent, + onAnswerSelected: (pollStartId: EventId, answerId: String) -> Unit, + onPollEdit: (pollStartId: EventId) -> Unit, + onPollEnd: (pollStartId: EventId) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Text( + text = pollContentItem.formattedDate, + color = MaterialTheme.colorScheme.secondary, + style = ElementTheme.typography.fontBodySmRegular, + ) + Spacer(modifier = Modifier.height(4.dp)) + PollContentView( + state = pollContentItem.state, + onAnswerSelected = onAnswerSelected, + onPollEdit = onPollEdit, + onPollEnd = onPollEnd, + ) + } +} + @PreviewsDayNight @Composable internal fun PollHistoryViewPreview( diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/model/DefaultPollContentStateFactory.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/model/DefaultPollContentStateFactory.kt new file mode 100644 index 0000000000..f48d2f54dd --- /dev/null +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/model/DefaultPollContentStateFactory.kt @@ -0,0 +1,80 @@ +/* + * 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 io.element.android.features.poll.impl.model + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.poll.api.pollcontent.PollAnswerItem +import io.element.android.features.poll.api.pollcontent.PollContentState +import io.element.android.features.poll.api.pollcontent.PollContentStateFactory +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.poll.isDisclosed +import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.event.PollContent +import kotlinx.collections.immutable.toImmutableList +import javax.inject.Inject + +@ContributesBinding(RoomScope::class) +class DefaultPollContentStateFactory @Inject constructor( + private val matrixClient: MatrixClient, +) : PollContentStateFactory { + + override suspend fun create( + event: EventTimelineItem, + content: PollContent + ): PollContentState { + val totalVoteCount = content.votes.flatMap { it.value }.size + val myVotes = content.votes.filter { matrixClient.sessionId in it.value }.keys + val isPollEnded = content.endTime != null + val winnerIds = if (!isPollEnded) { + emptyList() + } else { + content.answers + .map { answer -> answer.id } + .groupBy { answerId -> content.votes[answerId]?.size ?: 0 } // Group by votes count + .maxByOrNull { (votes, _) -> votes } // Keep max voted answers + ?.takeIf { (votes, _) -> votes > 0 } // Ignore if no option has been voted + ?.value + .orEmpty() + } + val answerItems = content.answers.map { answer -> + val answerVoteCount = content.votes[answer.id]?.size ?: 0 + val isSelected = answer.id in myVotes + val isWinner = answer.id in winnerIds + val percentage = if (totalVoteCount > 0) answerVoteCount.toFloat() / totalVoteCount.toFloat() else 0f + PollAnswerItem( + answer = answer, + isSelected = isSelected, + isEnabled = !isPollEnded, + isWinner = isWinner, + isDisclosed = content.kind.isDisclosed || isPollEnded, + votesCount = answerVoteCount, + percentage = percentage, + ) + } + + return PollContentState( + eventId = event.eventId, + isMine = event.isOwn, + question = content.question, + answerItems = answerItems.toImmutableList(), + pollKind = content.kind, + isPollEditable = event.isEditable, + isPollEnded = isPollEnded, + ) + } +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/MatrixTimeline.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/MatrixTimeline.kt index 8dfa2e4819..98d5772d23 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/MatrixTimeline.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/MatrixTimeline.kt @@ -20,7 +20,7 @@ import io.element.android.libraries.matrix.api.core.EventId import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow -interface MatrixTimeline: AutoCloseable { +interface MatrixTimeline : AutoCloseable { data class PaginationState( val isBackPaginating: Boolean, @@ -35,6 +35,5 @@ interface MatrixTimeline: AutoCloseable { suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int): Result suspend fun fetchDetailsForEvent(eventId: EventId): Result - suspend fun sendReadReceipt(eventId: EventId): Result } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt index 85f59f9e57..70978c4fe7 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt @@ -192,15 +192,19 @@ class RustMatrixTimeline( } } - override suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int): Result = withContext(dispatcher) { + override suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int): Result { + val paginationOptions = PaginationOptions.UntilNumItems( + eventLimit = requestSize.toUShort(), + items = untilNumberOfItems.toUShort(), + waitForToken = true, + ) + return paginateBackwards(paginationOptions) + } + + private suspend fun paginateBackwards(paginationOptions: PaginationOptions): Result = withContext(dispatcher) { runCatching { if (!canBackPaginate()) throw TimelineException.CannotPaginate Timber.v("Start back paginating for room ${matrixRoom.roomId} ") - val paginationOptions = PaginationOptions.UntilNumItems( - eventLimit = requestSize.toUShort(), - items = untilNumberOfItems.toUShort(), - waitForToken = true, - ) innerTimeline.paginateBackwards(paginationOptions) }.onFailure { error -> if (error is TimelineException.CannotPaginate) {