Polls: share logic around PollContent

This commit is contained in:
ganfra 2023-12-05 14:06:59 +01:00
parent d0d73e04c1
commit 4a2cbb1ed4
31 changed files with 571 additions and 149 deletions

View file

@ -0,0 +1,42 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.poll.impl.actions
import com.squareup.anvil.annotations.ContributesBinding
import im.vector.app.features.analytics.plan.PollEnd
import io.element.android.features.poll.api.actions.EndPollAction
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.services.analytics.api.AnalyticsService
import javax.inject.Inject
@ContributesBinding(RoomScope::class)
class DefaultEndPollAction @Inject constructor(
private val room: MatrixRoom,
private val analyticsService: AnalyticsService,
) : EndPollAction {
override suspend fun execute(pollStartId: EventId): Result<Unit> {
return room.endPoll(
pollStartId = pollStartId,
text = "The poll with event id: $pollStartId has ended."
).onSuccess {
analyticsService.capture(PollEnd())
}
}
}

View file

@ -0,0 +1,43 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.poll.impl.actions
import com.squareup.anvil.annotations.ContributesBinding
import im.vector.app.features.analytics.plan.PollVote
import io.element.android.features.poll.api.actions.SendPollResponseAction
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.services.analytics.api.AnalyticsService
import javax.inject.Inject
@ContributesBinding(RoomScope::class)
class DefaultSendPollResponseAction @Inject constructor(
private val room: MatrixRoom,
private val analyticsService: AnalyticsService,
) : SendPollResponseAction {
override suspend fun execute(pollStartId: EventId, answerId: String): Result<Unit> {
return room.sendPollResponse(
pollStartId = pollStartId,
answers = listOf(answerId),
).onSuccess {
analyticsService.capture(PollVote())
}
}
}

View file

@ -16,6 +16,11 @@
package io.element.android.features.poll.impl.history
import io.element.android.libraries.matrix.api.core.EventId
sealed interface PollHistoryEvents {
data object History : PollHistoryEvents
data object LoadMore : PollHistoryEvents
data class PollAnswerSelected(val pollStartId: EventId, val answerId: String) : PollHistoryEvents
data class PollEndClicked(val pollStartId: EventId) : PollHistoryEvents
data object EditPoll : PollHistoryEvents
}

View file

@ -0,0 +1,23 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.poll.impl.history
import io.element.android.features.poll.api.pollcontent.PollContentState
sealed interface PollHistoryItem {
data class PollContent(val formattedDate: String, val state: PollContentState) : PollHistoryItem
}

View file

@ -0,0 +1,51 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.poll.impl.history
import io.element.android.features.poll.api.pollcontent.PollContentStateFactory
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.dateformatter.api.DaySeparatorFormatter
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import kotlinx.coroutines.withContext
import javax.inject.Inject
class PollHistoryItemsFactory @Inject constructor(
private val pollContentStateFactory: PollContentStateFactory,
private val daySeparatorFormatter: DaySeparatorFormatter,
private val dispatchers: CoroutineDispatchers,
) {
suspend fun create(timelineItems: List<MatrixTimelineItem>): List<PollHistoryItem> = withContext(dispatchers.computation) {
timelineItems.mapNotNull { create(it) }.reversed()
}
private suspend fun create(timelineItem: MatrixTimelineItem): PollHistoryItem? {
return when (timelineItem) {
is MatrixTimelineItem.Event -> {
val pollContent = timelineItem.event.content as? PollContent ?: return null
val pollContentState = pollContentStateFactory.create(timelineItem.event, pollContent)
PollHistoryItem.PollContent(
formattedDate = daySeparatorFormatter.format(timelineItem.event.timestamp),
state = pollContentState
)
}
else -> null
}
}
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,80 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.poll.impl.model
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.poll.api.pollcontent.PollAnswerItem
import io.element.android.features.poll.api.pollcontent.PollContentState
import io.element.android.features.poll.api.pollcontent.PollContentStateFactory
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.poll.isDisclosed
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import kotlinx.collections.immutable.toImmutableList
import javax.inject.Inject
@ContributesBinding(RoomScope::class)
class DefaultPollContentStateFactory @Inject constructor(
private val matrixClient: MatrixClient,
) : PollContentStateFactory {
override suspend fun create(
event: EventTimelineItem,
content: PollContent
): PollContentState {
val totalVoteCount = content.votes.flatMap { it.value }.size
val myVotes = content.votes.filter { matrixClient.sessionId in it.value }.keys
val isPollEnded = content.endTime != null
val winnerIds = if (!isPollEnded) {
emptyList()
} else {
content.answers
.map { answer -> answer.id }
.groupBy { answerId -> content.votes[answerId]?.size ?: 0 } // Group by votes count
.maxByOrNull { (votes, _) -> votes } // Keep max voted answers
?.takeIf { (votes, _) -> votes > 0 } // Ignore if no option has been voted
?.value
.orEmpty()
}
val answerItems = content.answers.map { answer ->
val answerVoteCount = content.votes[answer.id]?.size ?: 0
val isSelected = answer.id in myVotes
val isWinner = answer.id in winnerIds
val percentage = if (totalVoteCount > 0) answerVoteCount.toFloat() / totalVoteCount.toFloat() else 0f
PollAnswerItem(
answer = answer,
isSelected = isSelected,
isEnabled = !isPollEnded,
isWinner = isWinner,
isDisclosed = content.kind.isDisclosed || isPollEnded,
votesCount = answerVoteCount,
percentage = percentage,
)
}
return PollContentState(
eventId = event.eventId,
isMine = event.isOwn,
question = content.question,
answerItems = answerItems.toImmutableList(),
pollKind = content.kind,
isPollEditable = event.isEditable,
isPollEnded = isPollEnded,
)
}
}