Merge pull request #1913 from element-hq/julioromano/poll_history_entry_point

Poll history UI
This commit is contained in:
Benoit Marty 2023-12-14 21:46:18 +01:00 committed by GitHub
commit 7283732f3b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
231 changed files with 2470 additions and 747 deletions

1
changelog.d/2014.feature Normal file
View file

@ -0,0 +1 @@
Poll history of a room is now accessible from the room details screen.

View file

@ -98,6 +98,8 @@ dependencies {
testImplementation(libs.test.mockk)
testImplementation(libs.test.junitext)
testImplementation(libs.test.robolectric)
testImplementation(projects.features.poll.test)
testImplementation(projects.features.poll.impl)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)

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
@ -53,7 +53,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
@ -70,11 +69,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
@ -133,18 +133,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

@ -521,8 +521,6 @@ private fun MessageEventBubbleContent(
) {
TimelineItemEventContentView(
content = event.content,
isMine = event.isMine,
isEditable = event.isEditable,
onLinkClicked = { url ->
when (val permalink = PermalinkParser.parse(Uri.parse(url))) {
is PermalinkData.UserLink -> {

View file

@ -80,8 +80,6 @@ fun TimelineItemStateEventRow(
) {
TimelineItemEventContentView(
content = event.content,
isMine = event.isMine,
isEditable = event.isEditable,
onLinkClicked = {},
extraPadding = noExtraPadding,
eventSink = eventSink,

View file

@ -40,8 +40,6 @@ import io.element.android.libraries.architecture.Presenter
@Composable
fun TimelineItemEventContentView(
content: TimelineItemEventContent,
isMine: Boolean,
isEditable: Boolean,
extraPadding: ExtraPadding,
onLinkClicked: (url: String) -> Unit,
eventSink: (TimelineEvents) -> Unit,
@ -98,8 +96,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,20 +67,6 @@ internal fun TimelineItemPollViewPreview(@PreviewParameter(TimelineItemPollConte
ElementPreview {
TimelineItemPollView(
content = content,
isMine = false,
isEditable = false,
eventSink = {},
)
}
@PreviewsDayNight
@Composable
internal fun TimelineItemPollCreatorViewPreview(@PreviewParameter(TimelineItemPollContentProvider::class) content: TimelineItemPollContent) =
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,64 +19,33 @@ 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,
isEdited = content.isEdited,
isMine = pollContentState.isMine,
isEditable = pollContentState.isPollEditable,
eventId = event.eventId,
question = pollContentState.question,
answerItems = pollContentState.answerItems,
pollKind = pollContentState.pollKind,
isEnded = pollContentState.isPollEnded,
isEdited = content.isEdited
)
}
}

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.PollAnswerItem
import io.element.android.features.poll.api.pollcontent.aPollAnswerItemList
import io.element.android.features.poll.api.pollcontent.aPollQuestion
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(isMine = true, isEditable = true),
)
}
fun aTimelineItemPollContent(
question: String = aPollQuestion(),
answerItems: List<PollAnswerItem> = aPollAnswerItemList(),
isMine: Boolean = false,
isEditable: Boolean = false,
isEnded: Boolean = false,
isEdited: Boolean = false,
): TimelineItemPollContent {
@ -42,6 +46,8 @@ fun aTimelineItemPollContent(
pollKind = PollKind.Disclosed,
question = question,
answerItems = answerItems,
isMine = isMine,
isEditable = isEditable,
isEnded = isEnded,
isEdited = isEdited,
)

View file

@ -21,7 +21,6 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.PollEnd
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
@ -48,6 +47,8 @@ import io.element.android.features.messages.impl.voicemessages.timeline.FakeReda
import io.element.android.features.messages.test.FakeMessageComposerContext
import io.element.android.features.messages.test.timeline.FakeHtmlConverterProvider
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.features.poll.test.actions.FakeEndPollAction
import io.element.android.features.poll.test.actions.FakeSendPollResponseAction
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
@ -91,7 +92,6 @@ import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.consumeItemsUntilTimeout
import io.element.android.tests.testutils.testCoroutineDispatchers
import io.element.android.tests.testutils.waitForPredicate
import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.TestScope
@ -619,29 +619,6 @@ class MessagesPresenterTest {
}
}
@Test
fun `present - handle poll end`() = runTest {
val room = FakeMatrixRoom()
val analyticsService = FakeAnalyticsService()
val presenter = createMessagesPresenter(
matrixRoom = room,
analyticsService = analyticsService,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.EndPoll, aMessageEvent()))
waitForPredicate { room.endPollInvocations.size == 1 }
cancelAndIgnoreRemainingEvents()
assertThat(room.endPollInvocations.size).isEqualTo(1)
assertThat(room.endPollInvocations.first().pollStartId).isEqualTo(AN_EVENT_ID)
assertThat(room.endPollInvocations.first().text).isEqualTo("The poll with event id: \$anEventId has ended.")
assertThat(analyticsService.capturedEvents.size).isEqualTo(1)
assertThat(analyticsService.capturedEvents.last()).isEqualTo(PollEnd())
}
}
@Test
fun `present - handle action reply to a poll`() = runTest {
val presenter = createMessagesPresenter()
@ -706,13 +683,14 @@ class MessagesPresenterTest {
dispatchers = coroutineDispatchers,
appScope = this,
navigator = navigator,
analyticsService = analyticsService,
encryptionService = FakeEncryptionService(),
verificationService = FakeSessionVerificationService(),
featureFlagService = FakeFeatureFlagService(),
redactedVoiceMessageManager = FakeRedactedVoiceMessageManager(),
endPollAction = FakeEndPollAction(),
sendPollResponseAction = FakeSendPollResponseAction(),
)
val timelinePresenterFactory = object: TimelinePresenter.Factory {
val timelinePresenterFactory = object : TimelinePresenter.Factory {
override fun create(navigator: MessagesNavigator): TimelinePresenter {
return timelinePresenter
}

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

@ -16,6 +16,7 @@
package io.element.android.features.messages.impl.fixtures
import io.element.android.features.messages.impl.timeline.aTimelineItemDebugInfo
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
import io.element.android.features.messages.impl.timeline.model.InReplyToDetails
import io.element.android.features.messages.impl.timeline.model.ReadReceiptData
@ -32,7 +33,6 @@ import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.room.aTimelineItemDebugInfo
import kotlinx.collections.immutable.toImmutableList
internal fun aMessageEvent(

View file

@ -33,6 +33,7 @@ import io.element.android.features.messages.impl.timeline.factories.virtual.Time
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemVirtualFactory
import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper
import io.element.android.features.messages.test.timeline.FakeHtmlConverterProvider
import io.element.android.features.poll.test.pollcontent.FakePollContentStateFactory
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter
import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter
@ -59,7 +60,7 @@ internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory {
),
redactedMessageFactory = TimelineItemContentRedactedFactory(),
stickerFactory = TimelineItemContentStickerFactory(),
pollFactory = TimelineItemContentPollFactory(matrixClient, FakeFeatureFlagService()),
pollFactory = TimelineItemContentPollFactory(FakeFeatureFlagService(), FakePollContentStateFactory()),
utdFactory = TimelineItemContentUTDFactory(),
roomMembershipFactory = TimelineItemContentRoomMembershipFactory(timelineEventFormatter),
profileChangeFactory = TimelineItemContentProfileChangeFactory(timelineEventFormatter),

View file

@ -53,5 +53,4 @@ class DefaultHtmlConverterProviderTest {
assertThat(htmlConverter).isNotNull()
}
}

View file

@ -20,10 +20,7 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.PollEnd
import im.vector.app.features.analytics.plan.PollVote
import io.element.android.features.messages.impl.FakeMessagesNavigator
import io.element.android.features.messages.impl.fixtures.aMessageEvent
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactory
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
import io.element.android.features.messages.impl.timeline.model.NewEventState
@ -32,8 +29,11 @@ import io.element.android.features.messages.impl.timeline.session.SessionState
import io.element.android.features.messages.impl.voicemessages.timeline.FakeRedactedVoiceMessageManager
import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager
import io.element.android.features.messages.impl.voicemessages.timeline.aRedactedMatrixTimeline
import io.element.android.features.poll.api.actions.EndPollAction
import io.element.android.features.poll.api.actions.SendPollResponseAction
import io.element.android.features.poll.test.actions.FakeEndPollAction
import io.element.android.features.poll.test.actions.FakeSendPollResponseAction
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.room.MatrixRoom
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.EventReaction
@ -43,18 +43,16 @@ import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTime
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aMessageContent
import io.element.android.libraries.matrix.test.room.anEventTimelineItem
import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
import io.element.android.libraries.matrix.test.timeline.aMessageContent
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.awaitLastSequentialItem
import io.element.android.tests.testutils.awaitWithLatch
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.testCoroutineDispatchers
import io.element.android.tests.testutils.waitForPredicate
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.TestScope
@ -295,12 +293,10 @@ class TimelinePresenterTest {
}
@Test
fun `present - PollAnswerSelected event calls into rust room api and analytics`() = runTest {
val room = FakeMatrixRoom()
val analyticsService = FakeAnalyticsService()
fun `present - PollAnswerSelected event`() = runTest {
val sendPollResponseAction = FakeSendPollResponseAction()
val presenter = createTimelinePresenter(
room = room,
analyticsService = analyticsService,
sendPollResponseAction = sendPollResponseAction,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@ -309,34 +305,23 @@ class TimelinePresenterTest {
initialState.eventSink.invoke(TimelineEvents.PollAnswerSelected(AN_EVENT_ID, "anAnswerId"))
}
delay(1)
assertThat(room.sendPollResponseInvocations.size).isEqualTo(1)
assertThat(room.sendPollResponseInvocations.first().answers).isEqualTo(listOf("anAnswerId"))
assertThat(room.sendPollResponseInvocations.first().pollStartId).isEqualTo(AN_EVENT_ID)
assertThat(analyticsService.capturedEvents.size).isEqualTo(1)
assertThat(analyticsService.capturedEvents.last()).isEqualTo(PollVote())
sendPollResponseAction.verifyExecutionCount(1)
}
@Test
fun `present - PollEndClicked event calls into rust room api and analytics`() = runTest {
val room = FakeMatrixRoom()
val analyticsService = FakeAnalyticsService()
fun `present - PollEndClicked event`() = runTest {
val endPollAction = FakeEndPollAction()
val presenter = createTimelinePresenter(
room = room,
analyticsService = analyticsService,
endPollAction = endPollAction,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(TimelineEvents.PollEndClicked(aMessageEvent().eventId!!))
waitForPredicate { room.endPollInvocations.size == 1 }
cancelAndIgnoreRemainingEvents()
assertThat(room.endPollInvocations.size).isEqualTo(1)
assertThat(room.endPollInvocations.first().pollStartId).isEqualTo(AN_EVENT_ID)
assertThat(room.endPollInvocations.first().text).isEqualTo("The poll with event id: \$anEventId has ended.")
assertThat(analyticsService.capturedEvents.size).isEqualTo(1)
assertThat(analyticsService.capturedEvents.last()).isEqualTo(PollEnd())
initialState.eventSink.invoke(TimelineEvents.PollEndClicked(AN_EVENT_ID))
}
delay(1)
endPollAction.verifyExecutionCount(1)
}
@Test
@ -379,36 +364,21 @@ class TimelinePresenterTest {
timelineItemsFactory: TimelineItemsFactory = aTimelineItemsFactory(),
redactedVoiceMessageManager: RedactedVoiceMessageManager = FakeRedactedVoiceMessageManager(),
messagesNavigator: FakeMessagesNavigator = FakeMessagesNavigator(),
): TimelinePresenter {
endPollAction: EndPollAction = FakeEndPollAction(),
sendPollResponseAction: SendPollResponseAction = FakeSendPollResponseAction(),
): TimelinePresenter {
return TimelinePresenter(
timelineItemsFactory = timelineItemsFactory,
room = FakeMatrixRoom(matrixTimeline = timeline),
dispatchers = testCoroutineDispatchers(),
appScope = this,
navigator = messagesNavigator,
analyticsService = FakeAnalyticsService(),
encryptionService = FakeEncryptionService(),
verificationService = FakeSessionVerificationService(),
featureFlagService = FakeFeatureFlagService(),
redactedVoiceMessageManager = redactedVoiceMessageManager,
)
}
private fun TestScope.createTimelinePresenter(
room: MatrixRoom,
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
): TimelinePresenter {
return TimelinePresenter(
timelineItemsFactory = aTimelineItemsFactory(),
room = room,
dispatchers = testCoroutineDispatchers(),
appScope = this,
navigator = FakeMessagesNavigator(),
analyticsService = analyticsService,
encryptionService = FakeEncryptionService(),
verificationService = FakeSessionVerificationService(),
featureFlagService = FakeFeatureFlagService(),
redactedVoiceMessageManager = FakeRedactedVoiceMessageManager(),
endPollAction = endPollAction,
sendPollResponseAction = sendPollResponseAction,
)
}
}

View file

@ -1,298 +0,0 @@
/*
* 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.messages.impl.timeline.factories.event
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.poll.api.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
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.poll.PollAnswer
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_10
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID_3
import io.element.android.libraries.matrix.test.A_USER_ID_4
import io.element.android.libraries.matrix.test.A_USER_ID_5
import io.element.android.libraries.matrix.test.A_USER_ID_6
import io.element.android.libraries.matrix.test.A_USER_ID_7
import io.element.android.libraries.matrix.test.A_USER_ID_8
import io.element.android.libraries.matrix.test.A_USER_ID_9
import io.element.android.libraries.matrix.test.FakeMatrixClient
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.test.runTest
import org.junit.Test
internal class TimelineItemContentPollFactoryTest {
private val factory = TimelineItemContentPollFactory(
matrixClient = FakeMatrixClient(),
featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.Polls.key to true)),
)
@Test
fun `Disclosed poll - not ended, no votes`() = runTest {
assertThat(factory.create(aPollContent(), eventId = null)).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()
assertThat(
factory.create(aPollContent(votes = votes), eventId = null)
)
.isEqualTo(
aTimelineItemPollContent(
answerItems = listOf(
aPollAnswerItem(answer = A_POLL_ANSWER_1, votesCount = 3, percentage = 0.3f),
aPollAnswerItem(answer = A_POLL_ANSWER_2, isSelected = true, votesCount = 6, percentage = 0.6f),
aPollAnswerItem(answer = A_POLL_ANSWER_3),
aPollAnswerItem(answer = A_POLL_ANSWER_4, votesCount = 1, percentage = 0.1f),
),
)
)
}
@Test
fun `Disclosed poll - ended, no votes, no winner`() = runTest {
assertThat(
factory.create(aPollContent(endTime = 1UL), eventId = null)
).isEqualTo(
aTimelineItemPollContent().let {
it.copy(
answerItems = it.answerItems.map { answerItem -> answerItem.copy(isEnabled = false) },
isEnded = true,
)
}
)
}
@Test
fun `Disclosed poll - ended, some votes, including one from current user (winner)`() = runTest {
val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }.toImmutableMap()
assertThat(
factory.create(aPollContent(votes = votes, endTime = 1UL), eventId = null)
)
.isEqualTo(
aTimelineItemPollContent(
answerItems = listOf(
aPollAnswerItem(answer = A_POLL_ANSWER_1, isEnabled = false, votesCount = 3, percentage = 0.3f),
aPollAnswerItem(answer = A_POLL_ANSWER_2, isSelected = true, isEnabled = false, isWinner = true, votesCount = 6, percentage = 0.6f),
aPollAnswerItem(answer = A_POLL_ANSWER_3, isEnabled = false),
aPollAnswerItem(answer = A_POLL_ANSWER_4, isEnabled = false, votesCount = 1, percentage = 0.1f),
),
isEnded = true,
)
)
}
@Test
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()
assertThat(
factory.create(aPollContent(votes = votes, endTime = 1UL), eventId = null)
)
.isEqualTo(
aTimelineItemPollContent(
answerItems = listOf(
aPollAnswerItem(answer = A_POLL_ANSWER_1, isEnabled = false, isWinner = true, votesCount = 4, percentage = 0.4f),
aPollAnswerItem(answer = A_POLL_ANSWER_2, isSelected = true, isEnabled = false, votesCount = 2, percentage = 0.2f),
aPollAnswerItem(answer = A_POLL_ANSWER_3, isEnabled = false),
aPollAnswerItem(answer = A_POLL_ANSWER_4, isEnabled = false, isWinner = true, votesCount = 4, percentage = 0.4f),
),
isEnded = true,
)
)
}
@Test
fun `Undisclosed poll - not ended, no votes`() = runTest {
assertThat(
factory.create(aPollContent(PollKind.Undisclosed).copy(), eventId = null)
).isEqualTo(
aTimelineItemPollContent(pollKind = PollKind.Undisclosed).let {
it.copy(answerItems = it.answerItems.map { answerItem -> answerItem.copy(isDisclosed = false) })
}
)
}
@Test
fun `Undisclosed poll - not ended, some votes, including one from current user`() = runTest {
val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }.toImmutableMap()
assertThat(
factory.create(aPollContent(pollKind = PollKind.Undisclosed, votes = votes), eventId = null)
)
.isEqualTo(
aTimelineItemPollContent(
pollKind = PollKind.Undisclosed,
answerItems = listOf(
aPollAnswerItem(answer = A_POLL_ANSWER_1, isDisclosed = false, votesCount = 3, percentage = 0.3f),
aPollAnswerItem(answer = A_POLL_ANSWER_2, isDisclosed = false, isSelected = true, votesCount = 6, percentage = 0.6f),
aPollAnswerItem(answer = A_POLL_ANSWER_3, isDisclosed = false),
aPollAnswerItem(answer = A_POLL_ANSWER_4, isDisclosed = false, votesCount = 1, percentage = 0.1f),
),
)
)
}
@Test
fun `Undisclosed poll - ended, no votes, no winner`() = runTest {
assertThat(
factory.create(aPollContent(pollKind = PollKind.Undisclosed, endTime = 1UL), eventId = null)
).isEqualTo(
aTimelineItemPollContent().let {
it.copy(
pollKind = PollKind.Undisclosed,
answerItems = it.answerItems.map { answerItem ->
answerItem.copy(isDisclosed = true, isEnabled = false, isWinner = false)
},
isEnded = true,
)
}
)
}
@Test
fun `Undisclosed poll - ended, some votes, including one from current user (winner)`() = runTest {
val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }.toImmutableMap()
assertThat(
factory.create(aPollContent(pollKind = PollKind.Undisclosed, votes = votes, endTime = 1UL), eventId = null)
)
.isEqualTo(
aTimelineItemPollContent(
pollKind = PollKind.Undisclosed,
answerItems = listOf(
aPollAnswerItem(answer = A_POLL_ANSWER_1, isEnabled = false, votesCount = 3, percentage = 0.3f),
aPollAnswerItem(answer = A_POLL_ANSWER_2, isSelected = true, isEnabled = false, isWinner = true, votesCount = 6, percentage = 0.6f),
aPollAnswerItem(answer = A_POLL_ANSWER_3, isEnabled = false),
aPollAnswerItem(answer = A_POLL_ANSWER_4, isEnabled = false, votesCount = 1, percentage = 0.1f),
),
isEnded = true,
)
)
}
@Test
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()
assertThat(
factory.create(aPollContent(PollKind.Undisclosed).copy(votes = votes, endTime = 1UL), eventId = null)
)
.isEqualTo(
aTimelineItemPollContent(
pollKind = PollKind.Undisclosed,
answerItems = listOf(
aPollAnswerItem(A_POLL_ANSWER_1, isEnabled = false, isWinner = true, votesCount = 4, percentage = 0.4f),
aPollAnswerItem(A_POLL_ANSWER_2, isSelected = true, isEnabled = false, votesCount = 2, percentage = 0.2f),
aPollAnswerItem(A_POLL_ANSWER_3, isEnabled = false),
aPollAnswerItem(A_POLL_ANSWER_4, isEnabled = false, isWinner = true, votesCount = 4, percentage = 0.4f),
),
isEnded = true,
)
)
}
@Test
fun `eventId is populated`() = runTest {
assertThat(factory.create(aPollContent(), eventId = null))
.isEqualTo(aTimelineItemPollContent(eventId = null))
assertThat(factory.create(aPollContent(), eventId = AN_EVENT_ID))
.isEqualTo(aTimelineItemPollContent(eventId = AN_EVENT_ID))
}
private fun aPollContent(
pollKind: PollKind = PollKind.Disclosed,
votes: ImmutableMap<String, ImmutableList<UserId>> = persistentMapOf(),
endTime: ULong? = null,
): PollContent = PollContent(
question = A_POLL_QUESTION,
kind = pollKind,
maxSelections = 1UL,
answers = persistentListOf(A_POLL_ANSWER_1, A_POLL_ANSWER_2, A_POLL_ANSWER_3, A_POLL_ANSWER_4),
votes = votes,
endTime = endTime,
isEdited = false,
)
private fun aTimelineItemPollContent(
eventId: EventId? = null,
pollKind: PollKind = PollKind.Disclosed,
answerItems: List<PollAnswerItem> = listOf(
aPollAnswerItem(A_POLL_ANSWER_1),
aPollAnswerItem(A_POLL_ANSWER_2),
aPollAnswerItem(A_POLL_ANSWER_3),
aPollAnswerItem(A_POLL_ANSWER_4),
),
isEnded: Boolean = false,
) = TimelineItemPollContent(
eventId = eventId,
question = A_POLL_QUESTION,
answerItems = answerItems,
pollKind = pollKind,
isEnded = isEnded,
isEdited = false,
)
private fun aPollAnswerItem(
answer: PollAnswer,
isSelected: Boolean = false,
isEnabled: Boolean = true,
isWinner: Boolean = false,
isDisclosed: Boolean = true,
votesCount: Int = 0,
percentage: Float = 0f,
) = PollAnswerItem(
answer = answer,
isSelected = isSelected,
isEnabled = isEnabled,
isWinner = isWinner,
isDisclosed = isDisclosed,
votesCount = votesCount,
percentage = percentage,
)
private companion object TestData {
private const val A_POLL_QUESTION = "What is your favorite food?"
private val A_POLL_ANSWER_1 = PollAnswer("id_1", "Pizza")
private val A_POLL_ANSWER_2 = PollAnswer("id_2", "Pasta")
private val A_POLL_ANSWER_3 = PollAnswer("id_3", "French Fries")
private val A_POLL_ANSWER_4 = PollAnswer("id_4", "Hamburger")
private val MY_USER_WINNING_VOTES = persistentMapOf(
A_POLL_ANSWER_1 to persistentListOf(A_USER_ID_2, A_USER_ID_3, A_USER_ID_4),
A_POLL_ANSWER_2 to persistentListOf(A_USER_ID /* my vote */, A_USER_ID_5, A_USER_ID_6, A_USER_ID_7, A_USER_ID_8, A_USER_ID_9), // winner
A_POLL_ANSWER_3 to persistentListOf(),
A_POLL_ANSWER_4 to persistentListOf(A_USER_ID_10),
)
private val OTHER_WINNING_VOTES = persistentMapOf(
A_POLL_ANSWER_1 to persistentListOf(A_USER_ID_2, A_USER_ID_3, A_USER_ID_4, A_USER_ID_5), // winner
A_POLL_ANSWER_2 to persistentListOf(A_USER_ID /* my vote */, A_USER_ID_6),
A_POLL_ANSWER_3 to persistentListOf(),
A_POLL_ANSWER_4 to persistentListOf(A_USER_ID_7, A_USER_ID_8, A_USER_ID_9, A_USER_ID_10), // winner
)
}
}

View file

@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.timeline.groups
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.fixtures.aMessageEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemDebugInfo
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
import io.element.android.features.messages.impl.timeline.model.ReadReceiptData
import io.element.android.features.messages.impl.timeline.model.TimelineItem
@ -28,7 +29,6 @@ import io.element.android.libraries.designsystem.components.avatar.anAvatarData
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.room.aTimelineItemDebugInfo
import kotlinx.collections.immutable.toImmutableList
import org.junit.Test

View file

@ -43,8 +43,8 @@ import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageT
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.media.aMediaSource
import io.element.android.libraries.matrix.test.room.aMessageContent
import io.element.android.libraries.matrix.test.room.aPollContent
import io.element.android.libraries.matrix.test.timeline.aMessageContent
import io.element.android.libraries.matrix.test.timeline.aPollContent
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
import kotlinx.coroutines.test.runTest

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

@ -0,0 +1,25 @@
/*
* 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.history
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import io.element.android.libraries.architecture.FeatureEntryPoint
interface PollHistoryEntryPoint : FeatureEntryPoint {
fun createNode(parentNode: Node, buildContext: BuildContext): Node
}

View file

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

View file

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

View file

@ -0,0 +1,41 @@
/*
* 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
/**
* UI model for a PollContent.
* @property eventId the event id of the poll.
* @property question the poll question.
* @property answerItems the list of answers.
* @property pollKind the kind of poll.
* @property isPollEditable whether the poll is editable.
* @property isPollEnded whether the poll is ended.
* @property isMine whether the poll has been created by me.
*/
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,9 +14,11 @@
* 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.PollKind
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
fun aPollQuestion() = "What type of food should we have at the party?"
@ -79,3 +81,25 @@ fun aPollAnswerItem(
votesCount = votesCount,
percentage = percentage
)
fun aPollContentState(
isMine: Boolean = false,
isEnded: Boolean = false,
isDisclosed: Boolean = true,
hasVotes: Boolean = true,
question: String = aPollQuestion(),
pollKind: PollKind = PollKind.Disclosed,
answerItems: ImmutableList<PollAnswerItem> = aPollAnswerItemList(
isEnded = isEnded,
isDisclosed = isDisclosed,
hasVotes = hasVotes
),
) = PollContentState(
eventId = null,
question = question,
answerItems = answerItems,
pollKind = pollKind,
isPollEditable = isMine && !isEnded,
isPollEnded = isEnded,
isMine = isMine,
)

View file

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

View file

@ -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)
@ -51,6 +52,8 @@ dependencies {
testImplementation(projects.services.analytics.test)
testImplementation(projects.features.messages.test)
testImplementation(projects.tests.testutils)
testImplementation(projects.libraries.dateformatter.test)
testImplementation(projects.features.poll.test)
ksp(libs.showkase.processor)
}

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

@ -0,0 +1,32 @@
/*
* 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 com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.poll.api.history.PollHistoryEntryPoint
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultPollHistoryEntryPoint @Inject constructor() : PollHistoryEntryPoint {
override fun createNode(parentNode: Node, buildContext: BuildContext): Node {
return parentNode.createNode<PollHistoryFlowNode>(buildContext)
}
}

View file

@ -0,0 +1,27 @@
/*
* 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.impl.history.model.PollHistoryFilter
import io.element.android.libraries.matrix.api.core.EventId
sealed interface PollHistoryEvents {
data object LoadMore : PollHistoryEvents
data class PollAnswerSelected(val pollStartId: EventId, val answerId: String) : PollHistoryEvents
data class PollEndClicked(val pollStartId: EventId) : PollHistoryEvents
data class OnFilterSelected(val filter: PollHistoryFilter) : PollHistoryEvents
}

View file

@ -0,0 +1,92 @@
/*
* 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 android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.poll.api.create.CreatePollEntryPoint
import io.element.android.features.poll.api.create.CreatePollMode
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
import kotlinx.parcelize.Parcelize
@ContributesNode(RoomScope::class)
class PollHistoryFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val createPollEntryPoint: CreatePollEntryPoint,
) : BackstackNode<PollHistoryFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Root,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins
) {
sealed interface NavTarget : Parcelable {
@Parcelize
data object Root : NavTarget
@Parcelize
data class EditPoll(val pollStartEventId: EventId) : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
is NavTarget.EditPoll -> {
createPollEntryPoint.nodeBuilder(this, buildContext)
.params(CreatePollEntryPoint.Params(mode = CreatePollMode.EditPoll(eventId = navTarget.pollStartEventId)))
.build()
}
NavTarget.Root -> {
val callback = object : PollHistoryNode.Callback {
override fun onEditPoll(pollStartEventId: EventId) {
backstack.push(NavTarget.EditPoll(pollStartEventId))
}
}
createNode<PollHistoryNode>(
buildContext = buildContext,
plugins = listOf(callback)
)
}
}
}
@Composable
override fun View(modifier: Modifier) {
Children(
navModel = backstack,
modifier = modifier,
transitionHandler = rememberDefaultTransitionHandler()
)
}
}

View file

@ -0,0 +1,58 @@
/*
* 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 androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
@ContributesNode(RoomScope::class)
class PollHistoryNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: PollHistoryPresenter,
) : Node(
buildContext = buildContext,
plugins = plugins,
) {
interface Callback : Plugin {
fun onEditPoll(pollStartEventId: EventId)
}
private fun onEditPoll(pollStartEventId: EventId) {
plugins<Callback>().forEach { it.onEditPoll(pollStartEventId) }
}
@Composable
override fun View(modifier: Modifier) {
PollHistoryView(
state = presenter.present(),
modifier = modifier,
onEditPoll = ::onEditPoll,
goBack = this::navigateUp,
)
}
}

View file

@ -0,0 +1,103 @@
/*
* 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 androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import io.element.android.features.poll.api.actions.EndPollAction
import io.element.android.features.poll.api.actions.SendPollResponseAction
import io.element.android.features.poll.impl.history.model.PollHistoryFilter
import io.element.android.features.poll.impl.history.model.PollHistoryItems
import io.element.android.features.poll.impl.history.model.PollHistoryItemsFactory
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import javax.inject.Inject
class PollHistoryPresenter @Inject constructor(
private val room: MatrixRoom,
private val appCoroutineScope: CoroutineScope,
private val sendPollResponseAction: SendPollResponseAction,
private val endPollAction: EndPollAction,
private val pollHistoryItemFactory: PollHistoryItemsFactory,
) : Presenter<PollHistoryState> {
@Composable
override fun present(): PollHistoryState {
// TODO use room.rememberPollHistory() when working properly?
val timeline = room.timeline
val paginationState by timeline.paginationState.collectAsState()
val pollHistoryItemsFlow = remember {
timeline.timelineItems.map { items ->
pollHistoryItemFactory.create(items)
}
}
var activeFilter by rememberSaveable {
mutableStateOf(PollHistoryFilter.ONGOING)
}
val pollHistoryItems by pollHistoryItemsFlow.collectAsState(initial = PollHistoryItems())
LaunchedEffect(paginationState, pollHistoryItems.size) {
if (pollHistoryItems.size == 0 && paginationState.canBackPaginate) loadMore(timeline)
}
val isLoading by remember {
derivedStateOf {
pollHistoryItems.size == 0 || paginationState.isBackPaginating
}
}
val coroutineScope = rememberCoroutineScope()
fun handleEvents(event: PollHistoryEvents) {
when (event) {
is PollHistoryEvents.LoadMore -> {
coroutineScope.loadMore(timeline)
}
is PollHistoryEvents.PollAnswerSelected -> appCoroutineScope.launch {
sendPollResponseAction.execute(pollStartId = event.pollStartId, answerId = event.answerId)
}
is PollHistoryEvents.PollEndClicked -> appCoroutineScope.launch {
endPollAction.execute(pollStartId = event.pollStartId)
}
is PollHistoryEvents.OnFilterSelected -> {
activeFilter = event.filter
}
}
}
return PollHistoryState(
isLoading = isLoading,
hasMoreToLoad = paginationState.hasMoreToLoadBackwards,
pollHistoryItems = pollHistoryItems,
activeFilter = activeFilter,
eventSink = ::handleEvents,
)
}
private fun CoroutineScope.loadMore(pollHistory: MatrixTimeline) = launch {
pollHistory.paginateBackwards(200)
}
}

View file

@ -0,0 +1,38 @@
/*
* 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.impl.history.model.PollHistoryFilter
import io.element.android.features.poll.impl.history.model.PollHistoryItem
import io.element.android.features.poll.impl.history.model.PollHistoryItems
import kotlinx.collections.immutable.ImmutableList
data class PollHistoryState(
val isLoading: Boolean,
val hasMoreToLoad: Boolean,
val activeFilter: PollHistoryFilter,
val pollHistoryItems: PollHistoryItems,
val eventSink: (PollHistoryEvents) -> Unit,
) {
fun pollHistoryForFilter(filter: PollHistoryFilter): ImmutableList<PollHistoryItem> {
return when (filter) {
PollHistoryFilter.ONGOING -> pollHistoryItems.ongoing
PollHistoryFilter.PAST -> pollHistoryItems.past
}
}
}

View file

@ -0,0 +1,68 @@
/*
* 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 androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.poll.api.pollcontent.PollContentState
import io.element.android.features.poll.api.pollcontent.aPollContentState
import io.element.android.features.poll.impl.history.model.PollHistoryFilter
import io.element.android.features.poll.impl.history.model.PollHistoryItem
import io.element.android.features.poll.impl.history.model.PollHistoryItems
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
class PollHistoryStateProvider : PreviewParameterProvider<PollHistoryState> {
override val values: Sequence<PollHistoryState>
get() = sequenceOf(
aPollHistoryState(
isLoading = false,
hasMoreToLoad = false,
activeFilter = PollHistoryFilter.ONGOING,
),
aPollHistoryState(
isLoading = true,
hasMoreToLoad = true,
activeFilter = PollHistoryFilter.PAST,
),
)
}
private fun aPollHistoryState(
isLoading: Boolean = false,
hasMoreToLoad: Boolean = false,
activeFilter: PollHistoryFilter = PollHistoryFilter.ONGOING,
currentItems: ImmutableList<PollHistoryItem> = persistentListOf(
aPollHistoryItem(),
),
) = PollHistoryState(
isLoading = isLoading,
hasMoreToLoad = hasMoreToLoad,
activeFilter = activeFilter,
pollHistoryItems = PollHistoryItems(
ongoing = currentItems,
past = currentItems,
),
eventSink = {},
)
private fun aPollHistoryItem(
formattedDate: String = "01/12/2023",
state: PollContentState = aPollContentState(),
) = PollHistoryItem(
formattedDate = formattedDate,
state = state,
)

View file

@ -0,0 +1,257 @@
/*
* 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 androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
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.pollcontent.PollContentView
import io.element.android.features.poll.impl.R
import io.element.android.features.poll.impl.history.model.PollHistoryFilter
import io.element.android.features.poll.impl.history.model.PollHistoryItem
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.Button
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.SegmentedButton
import io.element.android.libraries.designsystem.theme.components.Surface
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.core.EventId
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun PollHistoryView(
state: PollHistoryState,
onEditPoll: (EventId) -> Unit,
goBack: () -> Unit,
modifier: Modifier = Modifier,
) {
fun onLoadMore() {
state.eventSink(PollHistoryEvents.LoadMore)
}
fun onAnswerSelected(pollStartId: EventId, answerId: String) {
state.eventSink(PollHistoryEvents.PollAnswerSelected(pollStartId, answerId))
}
fun onPollEnd(pollStartId: EventId) {
state.eventSink(PollHistoryEvents.PollEndClicked(pollStartId))
}
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(R.string.screen_polls_history_title),
style = ElementTheme.typography.aliasScreenTitle,
)
},
navigationIcon = {
BackButton(onClick = goBack)
},
)
},
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.consumeWindowInsets(padding)
) {
val pagerState = rememberPagerState(state.activeFilter.ordinal, 0f) {
PollHistoryFilter.entries.size
}
LaunchedEffect(state.activeFilter) {
pagerState.scrollToPage(state.activeFilter.ordinal)
}
PollHistoryFilterButtons(
activeFilter = state.activeFilter,
onFilterSelected = { state.eventSink(PollHistoryEvents.OnFilterSelected(it)) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
)
HorizontalPager(
state = pagerState,
userScrollEnabled = false,
modifier = Modifier.fillMaxSize()
) { page ->
val filter = PollHistoryFilter.entries[page]
val pollHistoryItems = state.pollHistoryForFilter(filter)
PollHistoryList(
filter = filter,
pollHistoryItems = pollHistoryItems,
hasMoreToLoad = state.hasMoreToLoad,
isLoading = state.isLoading,
onAnswerSelected = ::onAnswerSelected,
onPollEdit = onEditPoll,
onPollEnd = ::onPollEnd,
onLoadMore = ::onLoadMore,
modifier = Modifier.fillMaxSize(),
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PollHistoryFilterButtons(
activeFilter: PollHistoryFilter,
onFilterSelected: (PollHistoryFilter) -> Unit,
modifier: Modifier = Modifier,
) {
SingleChoiceSegmentedButtonRow(modifier = modifier) {
PollHistoryFilter.entries.forEach { filter ->
SegmentedButton(
index = filter.ordinal,
count = PollHistoryFilter.entries.size,
selected = activeFilter == filter,
onClick = { onFilterSelected(filter) },
text = stringResource(filter.stringResource),
)
}
}
}
@Composable
private fun PollHistoryList(
filter: PollHistoryFilter,
pollHistoryItems: ImmutableList<PollHistoryItem>,
hasMoreToLoad: Boolean,
isLoading: Boolean,
onAnswerSelected: (pollStartId: EventId, answerId: String) -> Unit,
onPollEdit: (pollStartId: EventId) -> Unit,
onPollEnd: (pollStartId: EventId) -> Unit,
onLoadMore: () -> Unit,
modifier: Modifier = Modifier,
) {
val lazyListState = rememberLazyListState()
LazyColumn(
state = lazyListState,
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
) {
items(pollHistoryItems) { pollHistoryItem ->
PollHistoryItemRow(
pollHistoryItem = pollHistoryItem,
onAnswerSelected = onAnswerSelected,
onPollEdit = onPollEdit,
onPollEnd = onPollEnd,
modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp)
)
}
if (pollHistoryItems.isEmpty()) {
item {
val emptyStringResource = if (filter == PollHistoryFilter.PAST) {
stringResource(R.string.screen_polls_history_empty_past)
} else {
stringResource(R.string.screen_polls_history_empty_ongoing)
}
Text(
text = emptyStringResource,
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textSecondary,
modifier = Modifier.padding(vertical = 24.dp, horizontal = 16.dp)
)
}
}
if (hasMoreToLoad) {
item {
Button(
text = stringResource(CommonStrings.action_load_more),
showProgress = isLoading,
onClick = onLoadMore,
modifier = Modifier.padding(vertical = 24.dp),
)
}
}
}
}
@Composable
private fun PollHistoryItemRow(
pollHistoryItem: PollHistoryItem,
onAnswerSelected: (pollStartId: EventId, answerId: String) -> Unit,
onPollEdit: (pollStartId: EventId) -> Unit,
onPollEnd: (pollStartId: EventId) -> Unit,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier,
border = BorderStroke(1.dp, ElementTheme.colors.borderInteractiveSecondary),
shape = RoundedCornerShape(size = 12.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = pollHistoryItem.formattedDate,
color = MaterialTheme.colorScheme.secondary,
style = ElementTheme.typography.fontBodySmRegular,
)
Spacer(modifier = Modifier.height(4.dp))
PollContentView(
state = pollHistoryItem.state,
onAnswerSelected = onAnswerSelected,
onPollEdit = onPollEdit,
onPollEnd = onPollEnd,
)
}
}
}
@PreviewsDayNight
@Composable
internal fun PollHistoryViewPreview(
@PreviewParameter(PollHistoryStateProvider::class) state: PollHistoryState
) = ElementPreview {
PollHistoryView(
state = state,
onEditPoll = {},
goBack = {},
)
}

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.impl.history.model
import io.element.android.features.poll.impl.R
enum class PollHistoryFilter(val stringResource: Int) {
ONGOING(R.string.screen_polls_history_filter_ongoing),
PAST(R.string.screen_polls_history_filter_past),
}

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.impl.history.model
import io.element.android.features.poll.api.pollcontent.PollContentState
data class PollHistoryItem(
val formattedDate: String,
val state: PollContentState,
)

View file

@ -0,0 +1,27 @@
/*
* 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.model
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
data class PollHistoryItems(
val ongoing: ImmutableList<PollHistoryItem> = persistentListOf(),
val past: ImmutableList<PollHistoryItem> = persistentListOf(),
) {
val size = ongoing.size + past.size
}

View file

@ -0,0 +1,66 @@
/*
* 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.model
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.collections.immutable.toPersistentList
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>): PollHistoryItems = withContext(dispatchers.computation) {
val past = ArrayList<PollHistoryItem>()
val ongoing = ArrayList<PollHistoryItem>()
for (index in timelineItems.indices.reversed()) {
val timelineItem = timelineItems[index]
val pollHistoryItem = create(timelineItem) ?: continue
if (pollHistoryItem.state.isPollEnded) {
past.add(pollHistoryItem)
} else {
ongoing.add(pollHistoryItem)
}
}
PollHistoryItems(
ongoing = ongoing.toPersistentList(),
past = past.toPersistentList()
)
}
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(
formattedDate = daySeparatorFormatter.format(timelineItem.event.timestamp),
state = pollContentState
)
}
else -> null
}
}
}

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,
question = content.question,
answerItems = answerItems.toImmutableList(),
pollKind = content.kind,
isPollEditable = event.isEditable,
isPollEnded = isPollEnded,
isMine = event.isOwn,
)
}
}

View file

@ -11,4 +11,9 @@
<string name="screen_edit_poll_delete_confirmation">"Are you sure you want to delete this poll?"</string>
<string name="screen_edit_poll_delete_confirmation_title">"Delete Poll"</string>
<string name="screen_edit_poll_title">"Edit poll"</string>
<string name="screen_polls_history_empty_ongoing">"Can\'t find any ongoing polls."</string>
<string name="screen_polls_history_empty_past">"Can\'t find any past polls."</string>
<string name="screen_polls_history_filter_ongoing">"Ongoing"</string>
<string name="screen_polls_history_filter_past">"Past"</string>
<string name="screen_polls_history_title">"Polls"</string>
</resources>

View file

@ -0,0 +1,55 @@
/*
* 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
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.poll.PollAnswer
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
import io.element.android.libraries.matrix.test.timeline.aPollContent
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import kotlinx.collections.immutable.persistentListOf
fun aPollTimeline(
polls: Map<EventId, PollContent> = emptyMap(),
): FakeMatrixTimeline {
return FakeMatrixTimeline(
initialTimelineItems = polls.map { entry ->
MatrixTimelineItem.Event(
entry.key.hashCode().toLong(),
anEventTimelineItem(
eventId = entry.key,
content = entry.value,
)
)
}
)
}
fun anOngoingPollContent() = aPollContent(
question = "Do you like polls?",
answers = persistentListOf(
PollAnswer("1", "Yes"),
PollAnswer("2", "No"),
PollAnswer("2", "Maybe"),
),
)
fun anEndedPollContent() = anOngoingPollContent().copy(
endTime = 1702400215U
)

View file

@ -25,21 +25,17 @@ import im.vector.app.features.analytics.plan.Composer
import im.vector.app.features.analytics.plan.PollCreation
import io.element.android.features.messages.test.FakeMessageComposerContext
import io.element.android.features.poll.api.create.CreatePollMode
import io.element.android.features.poll.impl.aPollTimeline
import io.element.android.features.poll.impl.anOngoingPollContent
import io.element.android.features.poll.impl.data.PollRepository
import io.element.android.libraries.matrix.api.poll.PollAnswer
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.room.SavePollInvocation
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aPollContent
import io.element.android.libraries.matrix.test.room.anEventTimelineItem
import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
import io.element.android.libraries.matrix.test.room.SavePollInvocation
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest
import org.junit.Rule
@ -52,8 +48,12 @@ class CreatePollPresenterTest {
private val pollEventId = AN_EVENT_ID
private var navUpInvocationsCount = 0
private val existingPoll = anExistingPoll()
private val fakeMatrixRoom = createFakeMatrixRoom(existingPoll)
private val existingPoll = anOngoingPollContent()
private val fakeMatrixRoom = FakeMatrixRoom(
matrixTimeline = aPollTimeline(
mapOf(pollEventId to existingPoll)
)
)
private val fakeAnalyticsService = FakeAnalyticsService()
private val fakeMessageComposerContext = FakeMessageComposerContext()
@ -80,7 +80,9 @@ class CreatePollPresenterTest {
@Test
fun `in edit mode, if poll doesn't exist, error is tracked and screen is closed`() = runTest {
val room = createFakeMatrixRoom(existingPoll = null)
val room = FakeMatrixRoom(
matrixTimeline = aPollTimeline()
)
val presenter = createCreatePollPresenter(mode = CreatePollMode.EditPoll(AN_EVENT_ID), room = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@ -475,7 +477,6 @@ class CreatePollPresenterTest {
}
}
private suspend fun TurbineTestContext<CreatePollState>.awaitDefaultItem() =
awaitItem().apply {
assertThat(canSave).isFalse()
@ -518,35 +519,8 @@ class CreatePollPresenterTest {
navigateUp = { navUpInvocationsCount++ },
mode = mode,
)
private fun createFakeMatrixRoom(
existingPoll: PollContent? = anExistingPoll(),
) = FakeMatrixRoom(
matrixTimeline = FakeMatrixTimeline(
initialTimelineItems = existingPoll?.let {
listOf(
MatrixTimelineItem.Event(
0,
anEventTimelineItem(
eventId = pollEventId,
content = it,
)
)
)
}.orEmpty()
)
)
}
private fun anExistingPoll() = aPollContent(
question = "Do you like polls?",
answers = persistentListOf(
PollAnswer("1", "Yes"),
PollAnswer("2", "No"),
PollAnswer("2", "Maybe"),
),
)
private fun PollContent.expectedAnswersState() = answers.map { answer ->
Answer(
text = answer.text,

View file

@ -0,0 +1,169 @@
/*
* 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 app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.poll.api.actions.EndPollAction
import io.element.android.features.poll.api.actions.SendPollResponseAction
import io.element.android.features.poll.impl.aPollTimeline
import io.element.android.features.poll.impl.anEndedPollContent
import io.element.android.features.poll.impl.anOngoingPollContent
import io.element.android.features.poll.impl.history.model.PollHistoryFilter
import io.element.android.features.poll.impl.history.model.PollHistoryItemsFactory
import io.element.android.features.poll.impl.model.DefaultPollContentStateFactory
import io.element.android.features.poll.test.actions.FakeEndPollAction
import io.element.android.features.poll.test.actions.FakeSendPollResponseAction
import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class PollHistoryPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
private val room = FakeMatrixRoom(
matrixTimeline = aPollTimeline(
polls = mapOf(
AN_EVENT_ID to anOngoingPollContent(),
AN_EVENT_ID_2 to anEndedPollContent()
)
)
)
@Test
fun `present - initial states`() = runTest {
val presenter = createPollHistoryPresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().also { state ->
assertThat(state.activeFilter).isEqualTo(PollHistoryFilter.ONGOING)
assertThat(state.pollHistoryItems.size).isEqualTo(0)
assertThat(state.isLoading).isTrue()
assertThat(state.hasMoreToLoad).isTrue()
}
consumeItemsUntilPredicate {
it.pollHistoryItems.size == 2
}.last().also { state ->
assertThat(state.pollHistoryItems.size).isEqualTo(2)
assertThat(state.pollHistoryItems.ongoing).hasSize(1)
assertThat(state.pollHistoryItems.past).hasSize(1)
}
}
}
@Test
fun `present - change filter scenario`() = runTest {
val presenter = createPollHistoryPresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().also { state ->
assertThat(state.activeFilter).isEqualTo(PollHistoryFilter.ONGOING)
state.eventSink(PollHistoryEvents.OnFilterSelected(PollHistoryFilter.PAST))
}
consumeItemsUntilPredicate {
it.activeFilter == PollHistoryFilter.PAST
}.last().also { state ->
state.eventSink(PollHistoryEvents.OnFilterSelected(PollHistoryFilter.ONGOING))
}
consumeItemsUntilPredicate {
it.activeFilter == PollHistoryFilter.ONGOING
}
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - poll actions scenario`() = runTest {
val sendPollResponseAction = FakeSendPollResponseAction()
val endPollAction = FakeEndPollAction()
val presenter = createPollHistoryPresenter(
sendPollResponseAction = sendPollResponseAction,
endPollAction = endPollAction
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val state = awaitItem()
state.eventSink(PollHistoryEvents.PollEndClicked(AN_EVENT_ID))
runCurrent()
endPollAction.verifyExecutionCount(1)
state.eventSink(PollHistoryEvents.PollAnswerSelected(AN_EVENT_ID, "answer"))
runCurrent()
sendPollResponseAction.verifyExecutionCount(1)
cancelAndConsumeRemainingEvents()
}
}
@Test
fun `present - load more scenario`() = runTest {
val presenter = createPollHistoryPresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
consumeItemsUntilPredicate {
it.pollHistoryItems.size == 2 && !it.isLoading
}.last().also { state ->
state.eventSink(PollHistoryEvents.LoadMore)
}
consumeItemsUntilPredicate {
it.isLoading
}
consumeItemsUntilPredicate {
!it.isLoading
}
}
}
private fun TestScope.createPollHistoryPresenter(
room: MatrixRoom = FakeMatrixRoom(),
appCoroutineScope: CoroutineScope = this,
endPollAction: EndPollAction = FakeEndPollAction(),
sendPollResponseAction: SendPollResponseAction = FakeSendPollResponseAction(),
pollHistoryItemFactory: PollHistoryItemsFactory = PollHistoryItemsFactory(
pollContentStateFactory = DefaultPollContentStateFactory(FakeMatrixClient()),
daySeparatorFormatter = FakeDaySeparatorFormatter(),
dispatchers = testCoroutineDispatchers(),
),
): PollHistoryPresenter {
return PollHistoryPresenter(
room = room,
appCoroutineScope = appCoroutineScope,
sendPollResponseAction = sendPollResponseAction,
endPollAction = endPollAction,
pollHistoryItemFactory = pollHistoryItemFactory,
)
}
}

View file

@ -0,0 +1,289 @@
/*
* 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.pollcontent
import com.google.common.truth.Truth.assertThat
import io.element.android.features.poll.api.pollcontent.PollAnswerItem
import io.element.android.features.poll.api.pollcontent.PollContentState
import io.element.android.features.poll.impl.model.DefaultPollContentStateFactory
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.poll.PollAnswer
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_10
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID_3
import io.element.android.libraries.matrix.test.A_USER_ID_4
import io.element.android.libraries.matrix.test.A_USER_ID_5
import io.element.android.libraries.matrix.test.A_USER_ID_6
import io.element.android.libraries.matrix.test.A_USER_ID_7
import io.element.android.libraries.matrix.test.A_USER_ID_8
import io.element.android.libraries.matrix.test.A_USER_ID_9
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.test.runTest
import org.junit.Test
class PollContentStateFactoryTest {
private val factory = DefaultPollContentStateFactory(FakeMatrixClient())
private val eventTimelineItem = anEventTimelineItem()
@Test
fun `Disclosed poll - not ended, no votes`() = runTest {
val state = factory.create(eventTimelineItem, aPollContent())
val expectedState = aPollContentState()
assertThat(state).isEqualTo(expectedState)
}
@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()
val state = factory.create(
eventTimelineItem, aPollContent(votes = votes)
)
val expectedState = aPollContentState(
answerItems = listOf(
aPollAnswerItem(answer = A_POLL_ANSWER_1, votesCount = 3, percentage = 0.3f),
aPollAnswerItem(answer = A_POLL_ANSWER_2, isSelected = true, votesCount = 6, percentage = 0.6f),
aPollAnswerItem(answer = A_POLL_ANSWER_3),
aPollAnswerItem(answer = A_POLL_ANSWER_4, votesCount = 1, percentage = 0.1f),
)
)
assertThat(state).isEqualTo(expectedState)
}
@Test
fun `Disclosed poll - ended, no votes, no winner`() = runTest {
val state = factory.create(eventTimelineItem, aPollContent(endTime = 1UL))
val expectedState = aPollContentState().let {
it.copy(
answerItems = it.answerItems.map { answerItem -> answerItem.copy(isEnabled = false) }.toImmutableList(),
isPollEnded = true,
)
}
assertThat(state).isEqualTo(expectedState)
}
@Test
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 state = factory.create(
eventTimelineItem, aPollContent(votes = votes, endTime = 1UL)
)
val expectedState = aPollContentState(
answerItems = listOf(
aPollAnswerItem(answer = A_POLL_ANSWER_1, isEnabled = false, votesCount = 3, percentage = 0.3f),
aPollAnswerItem(answer = A_POLL_ANSWER_2, isSelected = true, isEnabled = false, isWinner = true, votesCount = 6, percentage = 0.6f),
aPollAnswerItem(answer = A_POLL_ANSWER_3, isEnabled = false),
aPollAnswerItem(answer = A_POLL_ANSWER_4, isEnabled = false, votesCount = 1, percentage = 0.1f),
),
isEnded = true,
)
assertThat(state).isEqualTo(expectedState)
}
@Test
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 state = factory.create(
eventTimelineItem, aPollContent(votes = votes, endTime = 1UL)
)
val expectedState = aPollContentState(
answerItems = listOf(
aPollAnswerItem(answer = A_POLL_ANSWER_1, isEnabled = false, isWinner = true, votesCount = 4, percentage = 0.4f),
aPollAnswerItem(answer = A_POLL_ANSWER_2, isSelected = true, isEnabled = false, votesCount = 2, percentage = 0.2f),
aPollAnswerItem(answer = A_POLL_ANSWER_3, isEnabled = false),
aPollAnswerItem(answer = A_POLL_ANSWER_4, isEnabled = false, isWinner = true, votesCount = 4, percentage = 0.4f),
),
isEnded = true,
)
assertThat(state).isEqualTo(expectedState)
}
@Test
fun `Undisclosed poll - not ended, no votes`() = runTest {
val state = factory.create(eventTimelineItem, aPollContent(PollKind.Undisclosed))
val expectedState = aPollContentState(pollKind = PollKind.Undisclosed).let {
it.copy(
answerItems = it.answerItems.map { answerItem -> answerItem.copy(isDisclosed = false) }.toImmutableList()
)
}
assertThat(state).isEqualTo(expectedState)
}
@Test
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 state = factory.create(
eventTimelineItem, aPollContent(PollKind.Undisclosed, votes = votes)
)
val expectedState = aPollContentState(
pollKind = PollKind.Undisclosed,
answerItems = listOf(
aPollAnswerItem(answer = A_POLL_ANSWER_1, isDisclosed = false, votesCount = 3, percentage = 0.3f),
aPollAnswerItem(answer = A_POLL_ANSWER_2, isDisclosed = false, isSelected = true, votesCount = 6, percentage = 0.6f),
aPollAnswerItem(answer = A_POLL_ANSWER_3, isDisclosed = false),
aPollAnswerItem(answer = A_POLL_ANSWER_4, isDisclosed = false, votesCount = 1, percentage = 0.1f),
),
)
assertThat(state).isEqualTo(expectedState)
}
@Test
fun `Undisclosed poll - ended, no votes, no winner`() = runTest {
val state = factory.create(eventTimelineItem, aPollContent(PollKind.Undisclosed, endTime = 1UL))
val expectedState = aPollContentState(
isEnded = true,
pollKind = PollKind.Undisclosed
).let {
it.copy(
answerItems = it.answerItems.map { answerItem -> answerItem.copy(isDisclosed = true, isEnabled = false) }.toImmutableList(),
)
}
assertThat(state).isEqualTo(expectedState)
}
@Test
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 state = factory.create(
eventTimelineItem, aPollContent(PollKind.Undisclosed, votes = votes, endTime = 1UL)
)
val expectedState = aPollContentState(
pollKind = PollKind.Undisclosed,
answerItems = listOf(
aPollAnswerItem(answer = A_POLL_ANSWER_1, isEnabled = false, votesCount = 3, percentage = 0.3f),
aPollAnswerItem(answer = A_POLL_ANSWER_2, isSelected = true, isEnabled = false, isWinner = true, votesCount = 6, percentage = 0.6f),
aPollAnswerItem(answer = A_POLL_ANSWER_3, isEnabled = false),
aPollAnswerItem(answer = A_POLL_ANSWER_4, isEnabled = false, votesCount = 1, percentage = 0.1f),
),
isEnded = true,
)
assertThat(state).isEqualTo(expectedState)
}
@Test
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 state = factory.create(
eventTimelineItem, aPollContent(PollKind.Undisclosed, votes = votes, endTime = 1UL)
)
val expectedState = aPollContentState(
pollKind = PollKind.Undisclosed,
answerItems = listOf(
aPollAnswerItem(answer = A_POLL_ANSWER_1, isEnabled = false, isWinner = true, votesCount = 4, percentage = 0.4f),
aPollAnswerItem(answer = A_POLL_ANSWER_2, isSelected = true, isEnabled = false, votesCount = 2, percentage = 0.2f),
aPollAnswerItem(answer = A_POLL_ANSWER_3, isEnabled = false),
aPollAnswerItem(answer = A_POLL_ANSWER_4, isEnabled = false, isWinner = true, votesCount = 4, percentage = 0.4f),
),
isEnded = true,
)
assertThat(state).isEqualTo(expectedState)
}
@Test
fun `eventId is populated`() = runTest {
val state = factory.create(eventTimelineItem, aPollContent())
assertThat(state.eventId).isEqualTo(eventTimelineItem.eventId)
}
private fun aPollContent(
pollKind: PollKind = PollKind.Disclosed,
votes: ImmutableMap<String, ImmutableList<UserId>> = persistentMapOf(),
endTime: ULong? = null,
): PollContent = PollContent(
question = A_POLL_QUESTION,
kind = pollKind,
maxSelections = 1UL,
answers = persistentListOf(A_POLL_ANSWER_1, A_POLL_ANSWER_2, A_POLL_ANSWER_3, A_POLL_ANSWER_4),
votes = votes,
endTime = endTime,
isEdited = false,
)
private fun aPollContentState(
eventId: EventId? = AN_EVENT_ID,
pollKind: PollKind = PollKind.Disclosed,
answerItems: List<PollAnswerItem> = listOf(
aPollAnswerItem(A_POLL_ANSWER_1),
aPollAnswerItem(A_POLL_ANSWER_2),
aPollAnswerItem(A_POLL_ANSWER_3),
aPollAnswerItem(A_POLL_ANSWER_4),
),
isEnded: Boolean = false,
isMine: Boolean = false,
isEditable: Boolean = false,
question: String = A_POLL_QUESTION,
) = PollContentState(
eventId = eventId,
question = question,
answerItems = answerItems.toImmutableList(),
pollKind = pollKind,
isPollEditable = isEditable,
isPollEnded = isEnded,
isMine = isMine,
)
private fun aPollAnswerItem(
answer: PollAnswer,
isSelected: Boolean = false,
isEnabled: Boolean = true,
isWinner: Boolean = false,
isDisclosed: Boolean = true,
votesCount: Int = 0,
percentage: Float = 0f,
) = PollAnswerItem(
answer = answer,
isSelected = isSelected,
isEnabled = isEnabled,
isWinner = isWinner,
isDisclosed = isDisclosed,
votesCount = votesCount,
percentage = percentage,
)
private companion object TestData {
private const val A_POLL_QUESTION = "What is your favorite food?"
private val A_POLL_ANSWER_1 = PollAnswer("id_1", "Pizza")
private val A_POLL_ANSWER_2 = PollAnswer("id_2", "Pasta")
private val A_POLL_ANSWER_3 = PollAnswer("id_3", "French Fries")
private val A_POLL_ANSWER_4 = PollAnswer("id_4", "Hamburger")
private val MY_USER_WINNING_VOTES = persistentMapOf(
A_POLL_ANSWER_1 to persistentListOf(A_USER_ID_2, A_USER_ID_3, A_USER_ID_4),
A_POLL_ANSWER_2 to persistentListOf(A_USER_ID /* my vote */, A_USER_ID_5, A_USER_ID_6, A_USER_ID_7, A_USER_ID_8, A_USER_ID_9), // winner
A_POLL_ANSWER_3 to persistentListOf(),
A_POLL_ANSWER_4 to persistentListOf(A_USER_ID_10),
)
private val OTHER_WINNING_VOTES = persistentMapOf(
A_POLL_ANSWER_1 to persistentListOf(A_USER_ID_2, A_USER_ID_3, A_USER_ID_4, A_USER_ID_5), // winner
A_POLL_ANSWER_2 to persistentListOf(A_USER_ID /* my vote */, A_USER_ID_6),
A_POLL_ANSWER_3 to persistentListOf(),
A_POLL_ANSWER_4 to persistentListOf(A_USER_ID_7, A_USER_ID_8, A_USER_ID_9, A_USER_ID_10), // winner
)
}
}

View file

@ -0,0 +1,29 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.features.poll.test"
}
dependencies {
implementation(projects.libraries.matrix.api)
api(projects.features.poll.api)
implementation(libs.kotlinx.collections.immutable)
}

View file

@ -0,0 +1,34 @@
/*
* 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.test.actions
import io.element.android.features.poll.api.actions.EndPollAction
import io.element.android.libraries.matrix.api.core.EventId
class FakeEndPollAction : EndPollAction {
private var executionCount = 0
fun verifyExecutionCount(count: Int) {
assert(executionCount == count)
}
override suspend fun execute(pollStartId: EventId): Result<Unit> {
executionCount++
return Result.success(Unit)
}
}

View file

@ -0,0 +1,34 @@
/*
* 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.test.actions
import io.element.android.features.poll.api.actions.SendPollResponseAction
import io.element.android.libraries.matrix.api.core.EventId
class FakeSendPollResponseAction : SendPollResponseAction {
private var executionCount = 0
fun verifyExecutionCount(count: Int) {
assert(executionCount == count)
}
override suspend fun execute(pollStartId: EventId, answerId: String): Result<Unit> {
executionCount++
return Result.success(Unit)
}
}

View file

@ -0,0 +1,39 @@
/*
* 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.test.pollcontent
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.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import kotlinx.collections.immutable.toImmutableList
class FakePollContentStateFactory : PollContentStateFactory {
override suspend fun create(event: EventTimelineItem, content: PollContent): PollContentState {
return PollContentState(
eventId = event.eventId,
question = content.question,
answerItems = emptyList<PollAnswerItem>().toImmutableList(),
pollKind = content.kind,
isPollEditable = event.isEditable,
isPollEnded = content.endTime != null,
isMine = event.isOwn
)
}
}

View file

@ -53,6 +53,7 @@ dependencies {
implementation(projects.features.leaveroom.api)
implementation(projects.features.createroom.api)
implementation(projects.services.analytics.api)
implementation(projects.features.poll.api)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)

View file

@ -29,6 +29,7 @@ import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.poll.api.history.PollHistoryEntryPoint
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditNode
import io.element.android.features.roomdetails.impl.invite.RoomInviteMembersNode
@ -52,6 +53,7 @@ import kotlinx.parcelize.Parcelize
class RoomDetailsFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val pollHistoryEntryPoint: PollHistoryEntryPoint,
) : BackstackNode<RoomDetailsFlowNode.NavTarget>(
backstack = BackStack(
initialElement = plugins.filterIsInstance<RoomDetailsEntryPoint.Params>().first().initialElement.toNavTarget(),
@ -88,6 +90,9 @@ class RoomDetailsFlowNode @AssistedInject constructor(
@Parcelize
data class AvatarPreview(val name: String, val avatarUrl: String) : NavTarget
@Parcelize
data object PollHistory : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@ -113,6 +118,10 @@ class RoomDetailsFlowNode @AssistedInject constructor(
override fun openAvatarPreview(name: String, url: String) {
backstack.push(NavTarget.AvatarPreview(name, url))
}
override fun openPollHistory() {
backstack.push(NavTarget.PollHistory)
}
}
createNode<RoomDetailsNode>(buildContext, listOf(roomDetailsCallback))
}
@ -178,6 +187,10 @@ class RoomDetailsFlowNode @AssistedInject constructor(
)
createNode<AvatarPreviewNode>(buildContext, listOf(input))
}
is NavTarget.PollHistory -> {
pollHistoryEntryPoint.createNode(this, buildContext)
}
}
}

View file

@ -53,6 +53,7 @@ class RoomDetailsNode @AssistedInject constructor(
fun editRoomDetails()
fun openRoomNotificationSettings()
fun openAvatarPreview(name: String, url: String)
fun openPollHistory()
}
private val callbacks = plugins<Callback>()
@ -77,6 +78,10 @@ class RoomDetailsNode @AssistedInject constructor(
callbacks.forEach { it.openInviteMembers() }
}
private fun openPollHistory() {
callbacks.forEach { it.openPollHistory() }
}
private fun onShareRoom(context: Context) {
val alias = room.alias ?: room.alternativeAliases.firstOrNull()
val permalinkResult = alias?.let { PermalinkBuilder.permalinkForRoomAlias(it) }
@ -146,6 +151,7 @@ class RoomDetailsNode @AssistedInject constructor(
openRoomNotificationSettings = ::openRoomNotificationSettings,
invitePeople = ::invitePeople,
openAvatarPreview = ::openAvatarPreview,
openPollHistory = ::openPollHistory,
)
}
}

View file

@ -92,6 +92,7 @@ fun RoomDetailsView(
openRoomNotificationSettings: () -> Unit,
invitePeople: () -> Unit,
openAvatarPreview: (name: String, url: String) -> Unit,
openPollHistory: () -> Unit,
modifier: Modifier = Modifier,
) {
fun onShareMember() {
@ -175,6 +176,10 @@ fun RoomDetailsView(
}
}
PollsSection(
openPollHistory = openPollHistory
)
if (state.isEncrypted) {
SecuritySection()
}
@ -379,6 +384,20 @@ private fun InviteSection(
}
}
@Composable
private fun PollsSection(
openPollHistory: () -> Unit,
modifier: Modifier = Modifier,
) {
PreferenceCategory(modifier = modifier) {
ListItem(
headlineContent = { Text(stringResource(R.string.screen_polls_history_title)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Polls)),
onClick = openPollHistory,
)
}
}
@Composable
private fun SecuritySection(modifier: Modifier = Modifier) {
PreferenceCategory(title = stringResource(R.string.screen_room_details_security_title), modifier = modifier) {
@ -424,5 +443,6 @@ private fun ContentToPreview(state: RoomDetailsState) {
openRoomNotificationSettings = {},
invitePeople = {},
openAvatarPreview = { _, _ -> },
openPollHistory = {},
)
}

View file

@ -5,6 +5,7 @@
<item quantity="other">"%1$d people"</item>
</plurals>
<string name="screen_notification_settings_edit_failed_updating_default_mode">"An error occurred while updating the notification setting."</string>
<string name="screen_polls_history_title">"Polls"</string>
<string name="screen_notification_settings_mentions_only_disclaimer">"Your homeserver does not support this option in encrypted rooms, you may not get notified in some rooms."</string>
<string name="screen_room_details_add_topic_title">"Add topic"</string>
<string name="screen_room_details_already_a_member">"Already a member"</string>

View file

@ -0,0 +1,70 @@
/*
* 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.libraries.designsystem.theme.components
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRowScope
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import io.element.android.compound.theme.ElementTheme
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SingleChoiceSegmentedButtonRowScope.SegmentedButton(
index: Int,
count: Int,
selected: Boolean,
onClick: () -> Unit,
text: String,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
enabled: Boolean = true,
) {
SegmentedButton(
selected = selected,
onClick = onClick,
modifier = modifier,
interactionSource = interactionSource,
enabled = enabled,
shape = SegmentedButtonDefaults.itemShape(index = index, count = count),
label = {
Text(
text = text,
style = ElementTheme.typography.fontBodyMdMedium,
)
},
colors = SegmentedButtonDefaults.colors(
activeContainerColor = ElementTheme.materialColors.primary,
activeContentColor = ElementTheme.materialColors.onPrimary,
activeBorderColor = ElementTheme.materialColors.primary,
inactiveContainerColor = ElementTheme.materialColors.surface,
inactiveContentColor = ElementTheme.materialColors.onSurface,
inactiveBorderColor = ElementTheme.materialColors.primary,
disabledActiveContainerColor = ElementTheme.colors.bgActionPrimaryDisabled,
disabledActiveContentColor = ElementTheme.colors.textOnSolidPrimary,
disabledActiveBorderColor = ElementTheme.colors.bgActionPrimaryDisabled,
disabledInactiveContainerColor = ElementTheme.materialColors.surface,
disabledInactiveContentColor = ElementTheme.colors.textDisabled,
disabledInactiveBorderColor = Color.Transparent,
)
)
}

View file

@ -50,9 +50,9 @@ import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageT
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.aPollContent
import io.element.android.libraries.matrix.test.room.aProfileChangeMessageContent
import io.element.android.libraries.matrix.test.room.anEventTimelineItem
import io.element.android.libraries.matrix.test.timeline.aPollContent
import io.element.android.libraries.matrix.test.timeline.aProfileChangeMessageContent
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import io.element.android.services.toolbox.impl.strings.AndroidStringProvider
import org.junit.Before
import org.junit.Test

View file

@ -241,7 +241,7 @@ interface MatrixRoom : Closeable {
*/
fun getWidgetDriver(widgetSettings: MatrixWidgetSettings): Result<MatrixWidgetDriver>
suspend fun pollHistory(): MatrixTimeline
fun pollHistory(): MatrixTimeline
override fun close() = destroy()
}

View file

@ -28,13 +28,21 @@ interface MatrixTimeline : AutoCloseable {
val beginningOfRoomReached: Boolean,
) {
val canBackPaginate = !isBackPaginating && hasMoreToLoadBackwards
companion object {
val Initial = PaginationState(
isBackPaginating = false,
hasMoreToLoadBackwards = true,
beginningOfRoomReached = false
)
}
}
val paginationState: StateFlow<PaginationState>
val timelineItems: Flow<List<MatrixTimelineItem>>
suspend fun paginateBackwards(requestSize: Int): Result<Unit>
suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int): Result<Unit>
suspend fun fetchDetailsForEvent(eventId: EventId): Result<Unit>
suspend fun sendReadReceipt(eventId: EventId, receiptType: ReceiptType): Result<Unit>
}

View file

@ -41,6 +41,7 @@ import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.api.room.roomNotificationSettings
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
import io.element.android.libraries.matrix.impl.core.toProgressWatcher
@ -50,6 +51,7 @@ import io.element.android.libraries.matrix.impl.media.toMSC3246range
import io.element.android.libraries.matrix.impl.notificationsettings.RustNotificationSettingsService
import io.element.android.libraries.matrix.impl.poll.toInner
import io.element.android.libraries.matrix.impl.room.location.toInner
import io.element.android.libraries.matrix.impl.timeline.AsyncMatrixTimeline
import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline
import io.element.android.libraries.matrix.impl.util.destroyAll
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
@ -70,14 +72,12 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.EventTimelineItem
import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.RoomInfo
import org.matrix.rustcomponents.sdk.RoomInfoListener
import org.matrix.rustcomponents.sdk.RoomListItem
import org.matrix.rustcomponents.sdk.RoomMember
import org.matrix.rustcomponents.sdk.RoomMessageEventContentWithoutRelation
import org.matrix.rustcomponents.sdk.SendAttachmentJoinHandle
import org.matrix.rustcomponents.sdk.Timeline
import org.matrix.rustcomponents.sdk.WidgetCapabilities
import org.matrix.rustcomponents.sdk.WidgetCapabilitiesProvider
import org.matrix.rustcomponents.sdk.messageEventContentFromHtml
@ -85,14 +85,16 @@ import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
import org.matrix.rustcomponents.sdk.use
import timber.log.Timber
import java.io.File
import org.matrix.rustcomponents.sdk.Room as InnerRoom
import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline
@OptIn(ExperimentalCoroutinesApi::class)
class RustMatrixRoom(
override val sessionId: SessionId,
private val isKeyBackupEnabled: Boolean,
private val roomListItem: RoomListItem,
private val innerRoom: Room,
private val innerTimeline: Timeline,
private val innerRoom: InnerRoom,
private val innerTimeline: InnerTimeline,
private val roomNotificationSettingsService: RustNotificationSettingsService,
sessionCoroutineScope: CoroutineScope,
private val coroutineDispatchers: CoroutineDispatchers,
@ -130,15 +132,9 @@ class RustMatrixRoom(
private val _roomNotificationSettingsStateFlow = MutableStateFlow<MatrixRoomNotificationSettingsState>(MatrixRoomNotificationSettingsState.Unknown)
override val roomNotificationSettingsStateFlow: StateFlow<MatrixRoomNotificationSettingsState> = _roomNotificationSettingsStateFlow
override val timeline = RustMatrixTimeline(
isKeyBackupEnabled = isKeyBackupEnabled,
matrixRoom = this,
innerTimeline = innerTimeline,
roomCoroutineScope = roomCoroutineScope,
dispatcher = roomDispatcher,
lastLoginTimestamp = sessionData.loginTimestamp,
onNewSyncedEvent = { _syncUpdateFlow.value = systemClock.epochMillis() }
)
override val timeline = createMatrixTimeline(innerTimeline) {
_syncUpdateFlow.value = systemClock.epochMillis()
}
override val membersStateFlow: StateFlow<MatrixRoomMembersState> = _membersStateFlow.asStateFlow()
@ -150,7 +146,7 @@ class RustMatrixRoom(
override fun destroy() {
roomCoroutineScope.cancel()
innerTimeline.destroy()
timeline.close()
innerRoom.destroy()
roomListItem.destroy()
specialModeEventTimelineItem?.destroy()
@ -564,15 +560,13 @@ class RustMatrixRoom(
)
}
override suspend fun pollHistory() = RustMatrixTimeline(
isKeyBackupEnabled = isKeyBackupEnabled,
matrixRoom = this,
innerTimeline = innerRoom.pollHistory(),
roomCoroutineScope = roomCoroutineScope,
dispatcher = roomDispatcher,
lastLoginTimestamp = sessionData.loginTimestamp,
onNewSyncedEvent = { _syncUpdateFlow.value = systemClock.epochMillis() }
)
override fun pollHistory() = AsyncMatrixTimeline(
coroutineScope = roomCoroutineScope,
dispatcher = roomDispatcher
) {
val innerTimeline = innerRoom.pollHistory()
createMatrixTimeline(innerTimeline)
}
private fun sendAttachment(files: List<File>, handle: () -> SendAttachmentJoinHandle): Result<MediaUploadHandler> {
return runCatching {
@ -580,6 +574,21 @@ class RustMatrixRoom(
}
}
private fun createMatrixTimeline(
timeline: InnerTimeline,
onNewSyncedEvent: () -> Unit = {},
): MatrixTimeline {
return RustMatrixTimeline(
isKeyBackupEnabled = isKeyBackupEnabled,
matrixRoom = this,
roomCoroutineScope = roomCoroutineScope,
dispatcher = roomDispatcher,
lastLoginTimestamp = sessionData.loginTimestamp,
onNewSyncedEvent = onNewSyncedEvent,
innerTimeline = timeline,
)
}
private fun messageEventContentFromParts(body: String, htmlBody: String?): RoomMessageEventContentWithoutRelation =
if (htmlBody != null) {
messageEventContentFromHtml(body, htmlBody)

View file

@ -0,0 +1,100 @@
/*
* 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.libraries.matrix.impl.timeline
import io.element.android.libraries.matrix.api.core.EventId
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.ReceiptType
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
/**
* This class is a wrapper around a [MatrixTimeline] that will be created asynchronously.
*/
class AsyncMatrixTimeline(
coroutineScope: CoroutineScope,
dispatcher: CoroutineDispatcher,
private val timelineProvider: suspend () -> MatrixTimeline
) : MatrixTimeline {
private val _timelineItems: MutableStateFlow<List<MatrixTimelineItem>> =
MutableStateFlow(emptyList())
private val _paginationState = MutableStateFlow(
MatrixTimeline.PaginationState.Initial
)
private val timeline = coroutineScope.async(context = dispatcher, start = CoroutineStart.LAZY) {
timelineProvider()
}
private val closeSignal = CompletableDeferred<Unit>()
init {
coroutineScope.launch {
val delegateTimeline = timeline.await()
delegateTimeline.timelineItems
.onEach { _timelineItems.value = it }
.launchIn(this)
delegateTimeline.paginationState
.onEach { _paginationState.value = it }
.launchIn(this)
launch {
withContext(NonCancellable) {
closeSignal.await()
Timber.d("Close delegate")
delegateTimeline.close()
}
}
}
}
override val paginationState: StateFlow<MatrixTimeline.PaginationState> = _paginationState
override val timelineItems: Flow<List<MatrixTimelineItem>> = _timelineItems
override suspend fun paginateBackwards(requestSize: Int): Result<Unit> {
return timeline.await().paginateBackwards(requestSize)
}
override suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int): Result<Unit> {
return timeline.await().paginateBackwards(requestSize, untilNumberOfItems)
}
override suspend fun fetchDetailsForEvent(eventId: EventId): Result<Unit> {
return timeline.await().fetchDetailsForEvent(eventId)
}
override suspend fun sendReadReceipt(eventId: EventId, receiptType: ReceiptType): Result<Unit> {
return timeline.await().sendReadReceipt(eventId, receiptType)
}
override fun close() {
closeSignal.complete(Unit)
}
}

View file

@ -73,11 +73,7 @@ class RustMatrixTimeline(
MutableStateFlow(emptyList())
private val _paginationState = MutableStateFlow(
MatrixTimeline.PaginationState(
hasMoreToLoadBackwards = true,
isBackPaginating = false,
beginningOfRoomReached = false,
)
MatrixTimeline.PaginationState.Initial
)
private val encryptedHistoryPostProcessor = TimelineEncryptedHistoryPostProcessor(
@ -131,7 +127,11 @@ class RustMatrixTimeline(
private suspend fun fetchMembers() = withContext(dispatcher) {
initLatch.await()
innerTimeline.fetchMembers()
try {
innerTimeline.fetchMembers()
} catch (exception: Exception) {
Timber.e(exception, "Error fetching members for room ${matrixRoom.roomId}")
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@ -193,15 +193,28 @@ class RustMatrixTimeline(
}
}
override suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int): Result<Unit> = withContext(dispatcher) {
override suspend fun paginateBackwards(requestSize: Int): Result<Unit> {
val paginationOptions = PaginationOptions.SimpleRequest(
eventLimit = requestSize.toUShort(),
waitForToken = true,
)
return paginateBackwards(paginationOptions)
}
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) {
initLatch.await()
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) {

View file

@ -20,7 +20,7 @@ import com.google.common.truth.Truth.assertThat
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.virtual.VirtualTimelineItem
import io.element.android.libraries.matrix.test.room.anEventTimelineItem
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope

View file

@ -431,7 +431,8 @@ class FakeMatrixRoom(
): Result<String> = generateWidgetWebViewUrlResult
override fun getWidgetDriver(widgetSettings: MatrixWidgetSettings): Result<MatrixWidgetDriver> = getWidgetDriverResult
override suspend fun pollHistory(): MatrixTimeline {
override fun pollHistory(): MatrixTimeline {
return FakeMatrixTimeline()
}

View file

@ -18,35 +18,17 @@ package io.element.android.libraries.matrix.test.room
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.poll.PollAnswer
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.message.RoomMessage
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageType
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import io.element.android.libraries.matrix.api.timeline.item.event.Receipt
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_NAME
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
fun aRoomSummaryFilled(
roomId: RoomId = A_ROOM_ID,
@ -101,96 +83,3 @@ fun aRoomMessage(
sender = userId,
originServerTs = timestamp,
)
fun anEventTimelineItem(
eventId: EventId = AN_EVENT_ID,
transactionId: TransactionId? = null,
isEditable: Boolean = false,
isLocal: Boolean = false,
isOwn: Boolean = false,
isRemote: Boolean = false,
localSendState: LocalEventSendState? = null,
reactions: ImmutableList<EventReaction> = persistentListOf(),
receipts: ImmutableList<Receipt> = persistentListOf(),
sender: UserId = A_USER_ID,
senderProfile: ProfileTimelineDetails = aProfileTimelineDetails(),
timestamp: Long = 0L,
content: EventContent = aProfileChangeMessageContent(),
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
) = EventTimelineItem(
eventId = eventId,
transactionId = transactionId,
isEditable = isEditable,
isLocal = isLocal,
isOwn = isOwn,
isRemote = isRemote,
localSendState = localSendState,
reactions = reactions,
receipts = receipts,
sender = sender,
senderProfile = senderProfile,
timestamp = timestamp,
content = content,
debugInfo = debugInfo,
origin = null,
)
fun aProfileTimelineDetails(
displayName: String? = A_USER_NAME,
displayNameAmbiguous: Boolean = false,
avatarUrl: String? = null
): ProfileTimelineDetails = ProfileTimelineDetails.Ready(
displayName = displayName,
displayNameAmbiguous = displayNameAmbiguous,
avatarUrl = avatarUrl,
)
fun aProfileChangeMessageContent(
displayName: String? = null,
prevDisplayName: String? = null,
avatarUrl: String? = null,
prevAvatarUrl: String? = null,
) = ProfileChangeContent(
displayName = displayName,
prevDisplayName = prevDisplayName,
avatarUrl = avatarUrl,
prevAvatarUrl = prevAvatarUrl,
)
fun aMessageContent(
body: String = "body",
inReplyTo: InReplyTo? = null,
isEdited: Boolean = false,
isThreaded: Boolean = false,
messageType: MessageType = TextMessageType(
body = body,
formatted = null
)
) = MessageContent(
body = body,
inReplyTo = inReplyTo,
isEdited = isEdited,
isThreaded = isThreaded,
type = messageType
)
fun aTimelineItemDebugInfo(
model: String = "Rust(Model())",
originalJson: String? = null,
latestEditedJson: String? = null,
) = TimelineItemDebugInfo(
model, originalJson, latestEditedJson
)
fun aPollContent(
question: String = "Do you like polls?",
answers: ImmutableList<PollAnswer> = persistentListOf(PollAnswer("1", "Yes"), PollAnswer("2", "No")),
) = PollContent(
question = question,
kind = PollKind.Disclosed,
maxSelections = 1u,
answers = answers,
votes = persistentMapOf(),
endTime = null,
isEdited = false,
)

View file

@ -57,7 +57,10 @@ class FakeMatrixTimeline(
override val timelineItems: Flow<List<MatrixTimelineItem>> = _timelineItems
override suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int): Result<Unit> {
override suspend fun paginateBackwards(requestSize: Int) = paginateBackwards()
override suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int) = paginateBackwards()
private suspend fun paginateBackwards(): Result<Unit> {
updatePaginationState {
copy(isBackPaginating = true)
}

View file

@ -0,0 +1,141 @@
/*
* 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.libraries.matrix.test.timeline
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.poll.PollAnswer
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageType
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import io.element.android.libraries.matrix.api.timeline.item.event.Receipt
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_NAME
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
fun anEventTimelineItem(
eventId: EventId = AN_EVENT_ID,
transactionId: TransactionId? = null,
isEditable: Boolean = false,
isLocal: Boolean = false,
isOwn: Boolean = false,
isRemote: Boolean = false,
localSendState: LocalEventSendState? = null,
reactions: ImmutableList<EventReaction> = persistentListOf(),
receipts: ImmutableList<Receipt> = persistentListOf(),
sender: UserId = A_USER_ID,
senderProfile: ProfileTimelineDetails = aProfileTimelineDetails(),
timestamp: Long = 0L,
content: EventContent = aProfileChangeMessageContent(),
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
) = EventTimelineItem(
eventId = eventId,
transactionId = transactionId,
isEditable = isEditable,
isLocal = isLocal,
isOwn = isOwn,
isRemote = isRemote,
localSendState = localSendState,
reactions = reactions,
receipts = receipts,
sender = sender,
senderProfile = senderProfile,
timestamp = timestamp,
content = content,
debugInfo = debugInfo,
origin = null,
)
fun aProfileTimelineDetails(
displayName: String? = A_USER_NAME,
displayNameAmbiguous: Boolean = false,
avatarUrl: String? = null
): ProfileTimelineDetails = ProfileTimelineDetails.Ready(
displayName = displayName,
displayNameAmbiguous = displayNameAmbiguous,
avatarUrl = avatarUrl,
)
fun aProfileChangeMessageContent(
displayName: String? = null,
prevDisplayName: String? = null,
avatarUrl: String? = null,
prevAvatarUrl: String? = null,
) = ProfileChangeContent(
displayName = displayName,
prevDisplayName = prevDisplayName,
avatarUrl = avatarUrl,
prevAvatarUrl = prevAvatarUrl,
)
fun aMessageContent(
body: String = "body",
inReplyTo: InReplyTo? = null,
isEdited: Boolean = false,
isThreaded: Boolean = false,
messageType: MessageType = TextMessageType(
body = body,
formatted = null
)
) = MessageContent(
body = body,
inReplyTo = inReplyTo,
isEdited = isEdited,
isThreaded = isThreaded,
type = messageType
)
fun aTimelineItemDebugInfo(
model: String = "Rust(Model())",
originalJson: String? = null,
latestEditedJson: String? = null,
) = TimelineItemDebugInfo(
model, originalJson, latestEditedJson
)
fun aPollContent(
question: String = "Do you like polls?",
answers: ImmutableList<PollAnswer> = persistentListOf(PollAnswer("1", "Yes"), PollAnswer("2", "No")),
kind: PollKind = PollKind.Disclosed,
maxSelections: ULong = 1u,
votes: ImmutableMap<String, ImmutableList<UserId>> = persistentMapOf(),
endTime: ULong? = null,
isEdited: Boolean = false,
) = PollContent(
question = question,
kind = kind,
maxSelections = maxSelections,
answers = answers,
votes = votes,
endTime = endTime,
isEdited = isEdited,
)

View file

@ -0,0 +1,36 @@
/*
* 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.libraries.matrix.ui.room
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
@Composable
fun MatrixRoom.rememberPollHistory(): MatrixTimeline {
val pollHistory = remember {
pollHistory()
}
DisposableEffect(pollHistory) {
onDispose {
pollHistory.close()
}
}
return pollHistory
}

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ea0b049a734767b1d68f75f808e398357772f37e80a8ad866280c74fc4671251
size 54035

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e2551e11572c82720ddde2909c03d00d3503dadbd0d26e5525cdf9801d61ccc9
size 50371

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cb71e2b69f31bdc3ee7bf17175f099c3838e311978bfed78ce40e6ad98c871e1
size 51991

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e7ff7a9241609b8990ad3353947e550607eca4d8809c0ace796a3cd3dd2ff079
size 48382

Some files were not shown because too many files have changed in this diff Show more