Polls: share logic around PollContent

This commit is contained in:
ganfra 2023-12-05 14:06:59 +01:00
parent 61b623041b
commit 67ec58a4f4
31 changed files with 571 additions and 149 deletions

View file

@ -30,14 +30,14 @@ import androidx.compose.runtime.saveable.rememberSaveable
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject 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.MessagesNavigator
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory 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.NewEventState
import io.element.android.features.messages.impl.timeline.model.TimelineItem 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.timeline.session.SessionState
import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager 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.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.featureflag.api.FeatureFlagService 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.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.ui.room.canSendMessageAsState import io.element.android.libraries.matrix.ui.room.canSendMessageAsState
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
@ -69,11 +68,12 @@ class TimelinePresenter @AssistedInject constructor(
private val dispatchers: CoroutineDispatchers, private val dispatchers: CoroutineDispatchers,
private val appScope: CoroutineScope, private val appScope: CoroutineScope,
@Assisted private val navigator: MessagesNavigator, @Assisted private val navigator: MessagesNavigator,
private val analyticsService: AnalyticsService,
private val verificationService: SessionVerificationService, private val verificationService: SessionVerificationService,
private val encryptionService: EncryptionService, private val encryptionService: EncryptionService,
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
private val redactedVoiceMessageManager: RedactedVoiceMessageManager, private val redactedVoiceMessageManager: RedactedVoiceMessageManager,
private val sendPollResponseAction: SendPollResponseAction,
private val endPollAction: EndPollAction,
) : Presenter<TimelineState> { ) : Presenter<TimelineState> {
@AssistedFactory @AssistedFactory
@ -132,18 +132,15 @@ class TimelinePresenter @AssistedInject constructor(
) )
} }
is TimelineEvents.PollAnswerSelected -> appScope.launch { is TimelineEvents.PollAnswerSelected -> appScope.launch {
room.sendPollResponse( sendPollResponseAction.execute(
pollStartId = event.pollStartId, pollStartId = event.pollStartId,
answers = listOf(event.answerId), answerId = event.answerId
) )
analyticsService.capture(PollVote())
} }
is TimelineEvents.PollEndClicked -> appScope.launch { is TimelineEvents.PollEndClicked -> appScope.launch {
room.endPoll( endPollAction.execute(
pollStartId = event.pollStartId, pollStartId = event.pollStartId,
text = "The poll with event id: ${event.pollStartId} has ended."
) )
analyticsService.capture(PollEnd())
} }
is TimelineEvents.PollEditClicked -> is TimelineEvents.PollEditClicked ->
navigator.onEditPollClicked(event.pollStartId) navigator.onEditPollClicked(event.pollStartId)

View file

@ -517,12 +517,10 @@ private fun MessageEventBubbleContent(
) { ) {
TimelineItemEventContentView( TimelineItemEventContentView(
content = event.content, content = event.content,
isMine = event.isMine,
isEditable = event.isEditable,
interactionSource = interactionSource, interactionSource = interactionSource,
extraPadding = event.toExtraPadding(),
onClick = onMessageClick, onClick = onMessageClick,
onLongClick = onMessageLongClick, onLongClick = onMessageLongClick,
extraPadding = event.toExtraPadding(),
eventSink = eventSink, eventSink = eventSink,
modifier = contentModifier, modifier = contentModifier,
) )

View file

@ -80,12 +80,10 @@ fun TimelineItemStateEventRow(
) { ) {
TimelineItemEventContentView( TimelineItemEventContentView(
content = event.content, content = event.content,
isMine = event.isMine,
isEditable = event.isEditable,
interactionSource = interactionSource, interactionSource = interactionSource,
extraPadding = noExtraPadding,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
extraPadding = noExtraPadding,
eventSink = eventSink, eventSink = eventSink,
modifier = Modifier.defaultTimelineContentPadding() modifier = Modifier.defaultTimelineContentPadding()
) )

View file

@ -41,8 +41,6 @@ import io.element.android.libraries.architecture.Presenter
@Composable @Composable
fun TimelineItemEventContentView( fun TimelineItemEventContentView(
content: TimelineItemEventContent, content: TimelineItemEventContent,
isMine: Boolean,
isEditable: Boolean,
interactionSource: MutableInteractionSource, interactionSource: MutableInteractionSource,
extraPadding: ExtraPadding, extraPadding: ExtraPadding,
onClick: () -> Unit, onClick: () -> Unit,
@ -103,8 +101,6 @@ fun TimelineItemEventContentView(
) )
is TimelineItemPollContent -> TimelineItemPollView( is TimelineItemPollContent -> TimelineItemPollView(
content = content, content = content,
isMine = isMine,
isEditable = isEditable,
eventSink = eventSink, eventSink = eventSink,
modifier = modifier, modifier = modifier,
) )

View file

@ -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.TimelineEvents
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent 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.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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
@ -31,8 +31,6 @@ import kotlinx.collections.immutable.toImmutableList
@Composable @Composable
fun TimelineItemPollView( fun TimelineItemPollView(
content: TimelineItemPollContent, content: TimelineItemPollContent,
isMine: Boolean,
isEditable: Boolean,
eventSink: (TimelineEvents) -> Unit, eventSink: (TimelineEvents) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -54,8 +52,8 @@ fun TimelineItemPollView(
answerItems = content.answerItems.toImmutableList(), answerItems = content.answerItems.toImmutableList(),
pollKind = content.pollKind, pollKind = content.pollKind,
isPollEnded = content.isEnded, isPollEnded = content.isEnded,
isPollEditable = isEditable, isPollEditable = content.isEditable,
isMine = isMine, isMine = content.isMine,
onAnswerSelected = ::onAnswerSelected, onAnswerSelected = ::onAnswerSelected,
onPollEdit = ::onPollEdit, onPollEdit = ::onPollEdit,
onPollEnd = ::onPollEnd, onPollEnd = ::onPollEnd,
@ -69,8 +67,6 @@ internal fun TimelineItemPollViewPreview(@PreviewParameter(TimelineItemPollConte
ElementPreview { ElementPreview {
TimelineItemPollView( TimelineItemPollView(
content = content, content = content,
isMine = false,
isEditable = false,
eventSink = {}, eventSink = {},
) )
} }
@ -81,8 +77,6 @@ internal fun TimelineItemPollCreatorViewPreview(@PreviewParameter(TimelineItemPo
ElementPreview { ElementPreview {
TimelineItemPollView( TimelineItemPollView(
content = content, content = content,
isMine = true,
isEditable = false,
eventSink = {}, eventSink = {},
) )
} }

View file

@ -59,7 +59,7 @@ class TimelineItemContentFactory @Inject constructor(
is RoomMembershipContent -> roomMembershipFactory.create(eventTimelineItem) is RoomMembershipContent -> roomMembershipFactory.create(eventTimelineItem)
is StateContent -> stateFactory.create(eventTimelineItem) is StateContent -> stateFactory.create(eventTimelineItem)
is StickerContent -> stickerFactory.create(itemContent) is StickerContent -> stickerFactory.create(itemContent)
is PollContent -> pollFactory.create(itemContent, eventTimelineItem.eventId) is PollContent -> pollFactory.create(eventTimelineItem, itemContent)
is UnableToDecryptContent -> utdFactory.create(itemContent) is UnableToDecryptContent -> utdFactory.create(itemContent)
is UnknownContent -> TimelineItemUnknownContent is UnknownContent -> TimelineItemUnknownContent
} }

View file

@ -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.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent 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.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.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
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.PollContent import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import javax.inject.Inject import javax.inject.Inject
class TimelineItemContentPollFactory @Inject constructor( class TimelineItemContentPollFactory @Inject constructor(
private val matrixClient: MatrixClient,
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
private val pollContentStateFactory: PollContentStateFactory,
) { ) {
suspend fun create( suspend fun create(
event: EventTimelineItem,
content: PollContent, content: PollContent,
eventId: EventId?
): TimelineItemEventContent { ): TimelineItemEventContent {
if (!featureFlagService.isFeatureEnabled(FeatureFlags.Polls)) return TimelineItemUnknownContent if (!featureFlagService.isFeatureEnabled(FeatureFlags.Polls)) return TimelineItemUnknownContent
val pollContentState = pollContentStateFactory.create(event, content)
// 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,
)
}
return TimelineItemPollContent( return TimelineItemPollContent(
eventId = eventId, isMine = pollContentState.isMine,
question = content.question, isEditable = pollContentState.isPollEditable,
answerItems = answerItems, eventId = event.eventId,
pollKind = content.kind, question = pollContentState.question,
isEnded = isEndedPoll, answerItems = pollContentState.answerItems,
pollKind = pollContentState.pollKind,
isEnded = pollContentState.isPollEnded,
) )
} }
} }

View file

@ -16,11 +16,13 @@
package io.element.android.features.messages.impl.timeline.model.event 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.core.EventId
import io.element.android.libraries.matrix.api.poll.PollKind import io.element.android.libraries.matrix.api.poll.PollKind
data class TimelineItemPollContent( data class TimelineItemPollContent(
val isMine: Boolean,
val isEditable: Boolean,
val eventId: EventId?, val eventId: EventId?,
val question: String, val question: String,
val answerItems: List<PollAnswerItem>, val answerItems: List<PollAnswerItem>,

View file

@ -17,9 +17,9 @@
package io.element.android.features.messages.impl.timeline.model.event package io.element.android.features.messages.impl.timeline.model.event
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.poll.api.PollAnswerItem import io.element.android.features.poll.api.pollcontent.aPollAnswerItemList
import io.element.android.features.poll.api.aPollAnswerItemList import io.element.android.features.poll.api.pollcontent.aPollQuestion
import io.element.android.features.poll.api.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.core.EventId
import io.element.android.libraries.matrix.api.poll.PollKind import io.element.android.libraries.matrix.api.poll.PollKind
@ -28,12 +28,16 @@ open class TimelineItemPollContentProvider : PreviewParameterProvider<TimelineIt
get() = sequenceOf( get() = sequenceOf(
aTimelineItemPollContent(), aTimelineItemPollContent(),
aTimelineItemPollContent().copy(pollKind = PollKind.Undisclosed), aTimelineItemPollContent().copy(pollKind = PollKind.Undisclosed),
aTimelineItemPollContent().copy(isMine = true),
aTimelineItemPollContent().copy(isEditable = true),
) )
} }
fun aTimelineItemPollContent( fun aTimelineItemPollContent(
question: String = aPollQuestion(), question: String = aPollQuestion(),
answerItems: List<PollAnswerItem> = aPollAnswerItemList(), answerItems: List<PollAnswerItem> = aPollAnswerItemList(),
isMine: Boolean = false,
isEditable: Boolean = false,
isEnded: Boolean = false, isEnded: Boolean = false,
): TimelineItemPollContent { ): TimelineItemPollContent {
return TimelineItemPollContent( return TimelineItemPollContent(
@ -41,6 +45,8 @@ fun aTimelineItemPollContent(
pollKind = PollKind.Disclosed, pollKind = PollKind.Disclosed,
question = question, question = question,
answerItems = answerItems, answerItems = answerItems,
isMine = isMine,
isEditable = isEditable,
isEnded = isEnded, isEnded = isEnded,
) )
} }

View file

@ -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.aTimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent 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.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.featureflag.test.InMemoryPreferencesStore
import io.element.android.libraries.matrix.test.A_MESSAGE import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule

View file

@ -18,7 +18,7 @@ package io.element.android.features.messages.impl.timeline.factories.event
import com.google.common.truth.Truth import com.google.common.truth.Truth
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent 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.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
@ -55,14 +55,14 @@ internal class TimelineItemContentPollFactoryTest {
@Test @Test
fun `Disclosed poll - not ended, no votes`() = runTest { 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 @Test
fun `Disclosed poll - not ended, some votes, including one from current user`() = runTest { fun `Disclosed poll - not ended, some votes, including one from current user`() = runTest {
val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }.toImmutableMap() val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }.toImmutableMap()
Truth.assertThat( Truth.assertThat(
factory.create(aPollContent(votes = votes), eventId = null) factory.create(aPollContent(votes = votes), eventId = null, eventTimelineItem.isOwn)
) )
.isEqualTo( .isEqualTo(
aTimelineItemPollContent( aTimelineItemPollContent(
@ -79,7 +79,7 @@ internal class TimelineItemContentPollFactoryTest {
@Test @Test
fun `Disclosed poll - ended, no votes, no winner`() = runTest { fun `Disclosed poll - ended, no votes, no winner`() = runTest {
Truth.assertThat( Truth.assertThat(
factory.create(aPollContent(endTime = 1UL), eventId = null) factory.create(aPollContent(endTime = 1UL), eventId = null, eventTimelineItem.isOwn)
).isEqualTo( ).isEqualTo(
aTimelineItemPollContent().let { aTimelineItemPollContent().let {
it.copy( it.copy(
@ -94,7 +94,11 @@ internal class TimelineItemContentPollFactoryTest {
fun `Disclosed poll - ended, some votes, including one from current user (winner)`() = runTest { fun `Disclosed poll - ended, some votes, including one from current user (winner)`() = runTest {
val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }.toImmutableMap() val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }.toImmutableMap()
Truth.assertThat( Truth.assertThat(
factory.create(aPollContent(votes = votes, endTime = 1UL), eventId = null) factory.create(
aPollContent(votes = votes, endTime = 1UL),
eventId = null,
eventTimelineItem.isOwn
)
) )
.isEqualTo( .isEqualTo(
aTimelineItemPollContent( 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 { 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() val votes = OTHER_WINNING_VOTES.mapKeys { it.key.id }.toImmutableMap()
Truth.assertThat( Truth.assertThat(
factory.create(aPollContent(votes = votes, endTime = 1UL), eventId = null) factory.create(
aPollContent(votes = votes, endTime = 1UL),
eventId = null,
eventTimelineItem.isOwn
)
) )
.isEqualTo( .isEqualTo(
aTimelineItemPollContent( aTimelineItemPollContent(
@ -131,7 +139,11 @@ internal class TimelineItemContentPollFactoryTest {
@Test @Test
fun `Undisclosed poll - not ended, no votes`() = runTest { fun `Undisclosed poll - not ended, no votes`() = runTest {
Truth.assertThat( Truth.assertThat(
factory.create(aPollContent(PollKind.Undisclosed).copy(), eventId = null) factory.create(
aPollContent(PollKind.Undisclosed).copy(),
eventId = null,
eventTimelineItem.isOwn
)
).isEqualTo( ).isEqualTo(
aTimelineItemPollContent(pollKind = PollKind.Undisclosed).let { aTimelineItemPollContent(pollKind = PollKind.Undisclosed).let {
it.copy(answerItems = it.answerItems.map { answerItem -> answerItem.copy(isDisclosed = false) }) 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 { fun `Undisclosed poll - not ended, some votes, including one from current user`() = runTest {
val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }.toImmutableMap() val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }.toImmutableMap()
Truth.assertThat( 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( .isEqualTo(
aTimelineItemPollContent( aTimelineItemPollContent(
@ -161,7 +177,11 @@ internal class TimelineItemContentPollFactoryTest {
@Test @Test
fun `Undisclosed poll - ended, no votes, no winner`() = runTest { fun `Undisclosed poll - ended, no votes, no winner`() = runTest {
Truth.assertThat( 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( ).isEqualTo(
aTimelineItemPollContent().let { aTimelineItemPollContent().let {
it.copy( it.copy(
@ -179,7 +199,11 @@ internal class TimelineItemContentPollFactoryTest {
fun `Undisclosed poll - ended, some votes, including one from current user (winner)`() = runTest { fun `Undisclosed poll - ended, some votes, including one from current user (winner)`() = runTest {
val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }.toImmutableMap() val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }.toImmutableMap()
Truth.assertThat( 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( .isEqualTo(
aTimelineItemPollContent( 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 { 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() val votes = OTHER_WINNING_VOTES.mapKeys { it.key.id }.toImmutableMap()
Truth.assertThat( 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( .isEqualTo(
aTimelineItemPollContent( aTimelineItemPollContent(
@ -217,10 +245,14 @@ internal class TimelineItemContentPollFactoryTest {
@Test @Test
fun `eventId is populated`() = runTest { 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)) .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)) .isEqualTo(aTimelineItemPollContent(eventId = AN_EVENT_ID))
} }

View file

@ -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<Unit>
}

View file

@ -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<Unit>
}

View file

@ -14,7 +14,7 @@
* limitations under the License. * 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 io.element.android.libraries.matrix.api.poll.PollAnswer

View file

@ -14,7 +14,7 @@
* limitations under the License. * 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.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row

View file

@ -14,7 +14,7 @@
* limitations under the License. * 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 io.element.android.libraries.matrix.api.poll.PollAnswer
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf

View file

@ -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<PollAnswerItem>,
val pollKind: PollKind,
val isPollEditable: Boolean,
val isPollEnded: Boolean,
val isMine: Boolean,
)

View file

@ -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
}

View file

@ -14,7 +14,7 @@
* limitations under the License. * 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.Arrangement
import androidx.compose.foundation.layout.Column 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 io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList 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 @Composable
fun PollContentView( fun PollContentView(
eventId: EventId?, eventId: EventId?,

View file

@ -40,6 +40,7 @@ dependencies {
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.services.analytics.api) implementation(projects.services.analytics.api)
implementation(projects.features.messages.api) implementation(projects.features.messages.api)
implementation(projects.libraries.dateformatter.api)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)

View file

@ -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<Unit> {
return room.endPoll(
pollStartId = pollStartId,
text = "The poll with event id: $pollStartId has ended."
).onSuccess {
analyticsService.capture(PollEnd())
}
}
}

View file

@ -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<Unit> {
return room.sendPollResponse(
pollStartId = pollStartId,
answers = listOf(answerId),
).onSuccess {
analyticsService.capture(PollVote())
}
}
}

View file

@ -16,6 +16,11 @@
package io.element.android.features.poll.impl.history package io.element.android.features.poll.impl.history
import io.element.android.libraries.matrix.api.core.EventId
sealed interface PollHistoryEvents { 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
} }

View file

@ -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
}

View file

@ -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<MatrixTimelineItem>): List<PollHistoryItem> = 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
}
}
}

View file

@ -17,22 +17,29 @@
package io.element.android.features.poll.impl.history package io.element.android.features.poll.impl.history
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject 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.architecture.Presenter
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline 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.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch
class PollHistoryPresenter @AssistedInject constructor( class PollHistoryPresenter @AssistedInject constructor(
@Assisted private val pollHistory: MatrixTimeline, @Assisted private val pollHistory: MatrixTimeline,
private val appCoroutineScope: CoroutineScope,
private val sendPollResponseAction: SendPollResponseAction,
private val endPollAction: EndPollAction,
private val pollHistoryItemFactory: PollHistoryItemsFactory,
) : Presenter<PollHistoryState> { ) : Presenter<PollHistoryState> {
@AssistedFactory @AssistedFactory
@ -47,26 +54,36 @@ class PollHistoryPresenter @AssistedInject constructor(
val paginationState by pollHistory.paginationState.collectAsState() val paginationState by pollHistory.paginationState.collectAsState()
val timelineItemsFlow = remember { val timelineItemsFlow = remember {
pollHistory.timelineItems.map { items -> pollHistory.timelineItems.map { items ->
items.filterIsInstance<MatrixTimelineItem.Event>() pollHistoryItemFactory.create(items)
.map { it.event.content }
.filterIsInstance<PollContent>()
.reversed()
}.onEach {
if (it.isEmpty()) pollHistory.paginateBackwards(20, 50)
} }
} }
val items by timelineItemsFlow.collectAsState(initial = emptyList()) val items by timelineItemsFlow.collectAsState(initial = emptyList())
LaunchedEffect(items.size) {
if (items.isEmpty()) loadMore()
}
val coroutineScope = rememberCoroutineScope()
fun handleEvents(event: PollHistoryEvents) { fun handleEvents(event: PollHistoryEvents) {
when (event) { 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( return PollHistoryState(
paginationState = paginationState, paginationState = paginationState,
matrixTimelineItems = items.toImmutableList(), pollItems = items.toImmutableList(),
eventSink = ::handleEvents, eventSink = ::handleEvents,
) )
} }
private fun CoroutineScope.loadMore() = launch {
pollHistory.paginateBackwards(20, 3)
}
} }

View file

@ -17,11 +17,10 @@
package io.element.android.features.poll.impl.history 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.MatrixTimeline
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
data class PollHistoryState( data class PollHistoryState(
val paginationState: MatrixTimeline.PaginationState, val paginationState: MatrixTimeline.PaginationState,
val matrixTimelineItems: ImmutableList<PollContent>, val pollItems: ImmutableList<PollHistoryItem>,
val eventSink: (PollHistoryEvents) -> Unit, val eventSink: (PollHistoryEvents) -> Unit,
) )

View file

@ -16,27 +16,31 @@
package io.element.android.features.poll.impl.history 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.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn 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.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.features.poll.api.PollAnswerItem import io.element.android.features.poll.api.pollcontent.PollContentView
import io.element.android.features.poll.api.PollContentView
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.aliasScreenTitle 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.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.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.poll.PollAnswer import io.element.android.libraries.matrix.api.core.EventId
import kotlinx.collections.immutable.toImmutableList
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -70,37 +74,75 @@ fun PollHistoryView(
if (state.paginationState.isBackPaginating) item { if (state.paginationState.isBackPaginating) item {
CircularProgressIndicator() CircularProgressIndicator()
} }
items(state.matrixTimelineItems) { pollContent -> itemsIndexed(state.pollItems) { index, pollHistoryItem ->
PollContentView( PollHistoryItemRow(
eventId = null, pollHistoryItem = pollHistoryItem,
question = pollContent.question, onAnswerSelected = fun(pollStartId: EventId, answerId: String) {
answerItems = pollContent.answers.map { state.eventSink(PollHistoryEvents.PollAnswerSelected(pollStartId, answerId))
PollAnswerItem( },
answer = PollAnswer( onPollEdit = {
id = it.id, state.eventSink(PollHistoryEvents.EditPoll)
text = it.text, },
), onPollEnd = {
isSelected = false, state.eventSink(PollHistoryEvents.PollEndClicked(it))
isEnabled = false, },
isWinner = false,
isDisclosed = false,
votesCount = 9393,
percentage = 4.5f,
)
}.toImmutableList(),
pollKind = pollContent.kind,
isPollEditable = false,
isPollEnded = false,
isMine = false,
onAnswerSelected = { _, _ -> },
onPollEdit = {},
onPollEnd = {},
) )
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 @PreviewsDayNight
@Composable @Composable
internal fun PollHistoryViewPreview( internal fun PollHistoryViewPreview(

View file

@ -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,
)
}
}

View file

@ -20,7 +20,7 @@ import io.element.android.libraries.matrix.api.core.EventId
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
interface MatrixTimeline: AutoCloseable { interface MatrixTimeline : AutoCloseable {
data class PaginationState( data class PaginationState(
val isBackPaginating: Boolean, val isBackPaginating: Boolean,
@ -35,6 +35,5 @@ interface MatrixTimeline: AutoCloseable {
suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int): Result<Unit> suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int): Result<Unit>
suspend fun fetchDetailsForEvent(eventId: EventId): Result<Unit> suspend fun fetchDetailsForEvent(eventId: EventId): Result<Unit>
suspend fun sendReadReceipt(eventId: EventId): Result<Unit> suspend fun sendReadReceipt(eventId: EventId): Result<Unit>
} }

View file

@ -192,15 +192,19 @@ class RustMatrixTimeline(
} }
} }
override suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int): Result<Unit> = withContext(dispatcher) { override suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int): Result<Unit> {
val paginationOptions = PaginationOptions.UntilNumItems(
eventLimit = requestSize.toUShort(),
items = untilNumberOfItems.toUShort(),
waitForToken = true,
)
return paginateBackwards(paginationOptions)
}
private suspend fun paginateBackwards(paginationOptions: PaginationOptions): Result<Unit> = withContext(dispatcher) {
runCatching { runCatching {
if (!canBackPaginate()) throw TimelineException.CannotPaginate if (!canBackPaginate()) throw TimelineException.CannotPaginate
Timber.v("Start back paginating for room ${matrixRoom.roomId} ") Timber.v("Start back paginating for room ${matrixRoom.roomId} ")
val paginationOptions = PaginationOptions.UntilNumItems(
eventLimit = requestSize.toUShort(),
items = untilNumberOfItems.toUShort(),
waitForToken = true,
)
innerTimeline.paginateBackwards(paginationOptions) innerTimeline.paginateBackwards(paginationOptions)
}.onFailure { error -> }.onFailure { error ->
if (error is TimelineException.CannotPaginate) { if (error is TimelineException.CannotPaginate) {