Polls: share logic around PollContent

This commit is contained in:
ganfra 2023-12-05 14:06:59 +01:00
parent d0d73e04c1
commit 4a2cbb1ed4
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.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<TimelineState> {
@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)

View file

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

View file

@ -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()
)

View file

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

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.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 = {},
)
}

View file

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

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

View file

@ -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<PollAnswerItem>,

View file

@ -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<TimelineIt
get() = sequenceOf(
aTimelineItemPollContent(),
aTimelineItemPollContent().copy(pollKind = PollKind.Undisclosed),
aTimelineItemPollContent().copy(isMine = true),
aTimelineItemPollContent().copy(isEditable = true),
)
}
fun aTimelineItemPollContent(
question: String = aPollQuestion(),
answerItems: List<PollAnswerItem> = 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,
)
}

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.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

View file

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