"View only" polls in the timeline (#1031)

* Handle poll events from the sdk

* Render started poll event in the timeline

* Create poll module

* Check poll kind before revealing the results

* Check if user has voted before revealing the results

* Add active poll previews

* Minor cleanup

* Update todos

* Fix CI

* Remove hardcoded string

* Update preview

* changelog file

* Update screenshots

* Use CommonPlurals

* Set poll root view as selectableGroup

* Improve poll result rendering

* Update screenshots

* Add missing showkase processor

* Update screenshots

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
Florian Renaud 2023-08-16 16:19:12 +02:00 committed by GitHub
parent efdfe15125
commit 8a62abe93e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
50 changed files with 1085 additions and 1 deletions

View file

@ -47,6 +47,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
@ -277,6 +278,7 @@ class MessagesPresenter @AssistedInject constructor(
is TimelineItemLocationContent -> AttachmentThumbnailInfo(
type = AttachmentThumbnailType.Location,
)
is TimelineItemPollContent, // TODO Polls: handle reply to
is TimelineItemTextBasedContent,
is TimelineItemRedactedContent,
is TimelineItemStateContent,

View file

@ -62,6 +62,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
@ -236,6 +237,7 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
val textContent = remember(event.content) { formatter.format(event) }
when (event.content) {
is TimelineItemPollContent, // TODO Polls: handle summary
is TimelineItemTextBasedContent,
is TimelineItemStateContent,
is TimelineItemEncryptedContent,

View file

@ -25,6 +25,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
@ -90,5 +91,10 @@ fun TimelineItemEventContentView(
content = content,
modifier = modifier
)
is TimelineItemPollContent -> TimelineItemPollView(
content = content,
onAnswerSelected = {},
modifier = modifier,
)
}
}

View file

@ -0,0 +1,53 @@
/*
* 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.components.event
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewParameter
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.ActivePollContentView
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.matrix.api.poll.PollAnswer
import kotlinx.collections.immutable.toImmutableList
@Composable
fun TimelineItemPollView(
content: TimelineItemPollContent,
onAnswerSelected: (PollAnswer) -> Unit,
modifier: Modifier = Modifier,
) {
ActivePollContentView(
question = content.question,
answerItems = content.answerItems.toImmutableList(),
pollKind = content.pollKind,
onAnswerSelected = onAnswerSelected,
modifier = modifier,
)
}
@DayNightPreviews
@Composable
internal fun TimelineItemPollViewPreview(@PreviewParameter(TimelineItemPollContentProvider::class) content: TimelineItemPollContent) =
ElementPreview {
TimelineItemPollView(
content = content,
onAnswerSelected = {},
)
}

View file

@ -22,6 +22,8 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventTimeline
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.api.timeline.item.event.PollEndContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
@ -35,6 +37,8 @@ class TimelineItemContentFactory @Inject constructor(
private val messageFactory: TimelineItemContentMessageFactory,
private val redactedMessageFactory: TimelineItemContentRedactedFactory,
private val stickerFactory: TimelineItemContentStickerFactory,
private val pollFactory: TimelineItemContentPollFactory,
private val pollEndFactory: TimelineItemContentPollEndFactory,
private val utdFactory: TimelineItemContentUTDFactory,
private val roomMembershipFactory: TimelineItemContentRoomMembershipFactory,
private val profileChangeFactory: TimelineItemContentProfileChangeFactory,
@ -53,6 +57,8 @@ class TimelineItemContentFactory @Inject constructor(
is RoomMembershipContent -> roomMembershipFactory.create(eventTimelineItem)
is StateContent -> stateFactory.create(eventTimelineItem)
is StickerContent -> stickerFactory.create(itemContent)
is PollContent -> pollFactory.create(itemContent)
is PollEndContent -> pollEndFactory.create(itemContent)
is UnableToDecryptContent -> utdFactory.create(itemContent)
is UnknownContent -> TimelineItemUnknownContent
}

View file

@ -0,0 +1,29 @@
/*
* 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 io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.libraries.matrix.api.timeline.item.event.PollEndContent
import javax.inject.Inject
class TimelineItemContentPollEndFactory @Inject constructor() {
fun create(@Suppress("UNUSED_PARAMETER") content: PollEndContent): TimelineItemEventContent {
return TimelineItemUnknownContent
}
}

View file

@ -0,0 +1,56 @@
/*
* 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 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.poll.api.PollAnswerItem
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import javax.inject.Inject
class TimelineItemContentPollFactory @Inject constructor(
private val matrixClient: MatrixClient,
) {
fun create(content: PollContent): TimelineItemEventContent {
// Todo Move this computation to the matrix rust sdk
val showResults = content.kind == PollKind.Disclosed && matrixClient.sessionId in content.votes.flatMap { it.value }
val pollVotesCount = content.votes.flatMap { it.value }.size
val userVotes = content.votes.filter { matrixClient.sessionId in it.value }.keys
val answerItems = content.answers.map { answer ->
val votesCount = content.votes[answer.id]?.size ?: 0
val progress = if (pollVotesCount > 0) votesCount.toFloat() / pollVotesCount.toFloat() else 0f
PollAnswerItem(
answer = answer,
isSelected = answer.id in userVotes,
isDisclosed = showResults,
votesCount = votesCount,
progress = progress,
)
}
return TimelineItemPollContent(
question = content.question,
answerItems = answerItems,
votes = content.votes,
pollKind = content.kind,
isDisclosed = showResults
)
}
}

View file

@ -22,6 +22,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemProfileChangeContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRoomMembershipContent
@ -33,6 +34,8 @@ import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.api.timeline.item.event.PollEndContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
@ -55,6 +58,7 @@ internal fun TimelineItem.Event.canBeGrouped(): Boolean {
is TimelineItemVideoContent,
is TimelineItemAudioContent,
is TimelineItemLocationContent,
is TimelineItemPollContent,
TimelineItemRedactedContent,
TimelineItemUnknownContent -> false
is TimelineItemProfileChangeContent,
@ -74,6 +78,8 @@ internal fun MatrixTimelineItem.Event.canBeDisplayedInBubbleBlock(): Boolean {
is MessageContent,
RedactedContent,
is StickerContent,
is PollContent,
is PollEndContent,
is UnableToDecryptContent -> true
is FailedToParseStateContent,
is ProfileChangeContent,

View file

@ -45,6 +45,7 @@ fun TimelineItemEventContent.canReact(): Boolean =
is TimelineItemFileContent,
is TimelineItemImageContent,
is TimelineItemLocationContent,
is TimelineItemPollContent,
is TimelineItemVideoContent -> true
is TimelineItemStateContent,
is TimelineItemRedactedContent,

View file

@ -0,0 +1,31 @@
/*
* 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.
*/
package io.element.android.features.messages.impl.timeline.model.event
import io.element.android.features.poll.api.PollAnswerItem
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.poll.PollKind
data class TimelineItemPollContent(
val question: String,
val answerItems: List<PollAnswerItem>,
val votes: Map<String, List<UserId>>,
val pollKind: PollKind,
val isDisclosed: Boolean,
) : TimelineItemEventContent {
override val type: String = "TimelineItemPollContent"
}

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.messages.impl.timeline.model.event
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.poll.api.aPollAnswerItemList
import io.element.android.libraries.matrix.api.poll.PollKind
open class TimelineItemPollContentProvider : PreviewParameterProvider<TimelineItemPollContent> {
override val values: Sequence<TimelineItemPollContent>
get() = sequenceOf(
aTimelineItemPollContent(),
aTimelineItemPollContent().copy(isDisclosed = true),
)
}
fun aTimelineItemPollContent(): TimelineItemPollContent {
return TimelineItemPollContent(
pollKind = PollKind.Disclosed,
isDisclosed = false,
question = "What type of food should we have at the party?",
answerItems = aPollAnswerItemList(),
votes = emptyMap(),
)
}

View file

@ -24,6 +24,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemProfileChangeContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
@ -47,6 +48,7 @@ class MessageSummaryFormatterImpl @Inject constructor(
is TimelineItemLocationContent -> context.getString(CommonStrings.common_shared_location)
is TimelineItemEncryptedContent -> context.getString(CommonStrings.common_unable_to_decrypt)
is TimelineItemRedactedContent -> context.getString(CommonStrings.common_message_removed)
is TimelineItemPollContent, // Todo Polls: handle summary
is TimelineItemUnknownContent -> context.getString(CommonStrings.common_unsupported_event)
is TimelineItemImageContent -> context.getString(CommonStrings.common_image)
is TimelineItemVideoContent -> context.getString(CommonStrings.common_video)

View file

@ -21,6 +21,8 @@ import io.element.android.features.messages.impl.timeline.factories.event.Timeli
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentFailedToParseMessageFactory
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentFailedToParseStateFactory
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentMessageFactory
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentPollEndFactory
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentPollFactory
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentProfileChangeFactory
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentRedactedFactory
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentRoomMembershipFactory
@ -42,6 +44,7 @@ import kotlinx.coroutines.test.TestScope
internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory {
val timelineEventFormatter = aTimelineEventFormatter()
val matrixClient = FakeMatrixClient()
return TimelineItemsFactory(
dispatchers = testCoroutineDispatchers(),
eventItemFactory = TimelineItemEventFactory(
@ -49,6 +52,8 @@ internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory {
messageFactory = TimelineItemContentMessageFactory(FakeFileSizeFormatter(), FileExtensionExtractorWithoutValidation()),
redactedMessageFactory = TimelineItemContentRedactedFactory(),
stickerFactory = TimelineItemContentStickerFactory(),
pollFactory = TimelineItemContentPollFactory(matrixClient),
pollEndFactory = TimelineItemContentPollEndFactory(),
utdFactory = TimelineItemContentUTDFactory(),
roomMembershipFactory = TimelineItemContentRoomMembershipFactory(timelineEventFormatter),
profileChangeFactory = TimelineItemContentProfileChangeFactory(timelineEventFormatter),
@ -56,7 +61,7 @@ internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory {
failedToParseMessageFactory = TimelineItemContentFailedToParseMessageFactory(),
failedToParseStateFactory = TimelineItemContentFailedToParseStateFactory()
),
matrixClient = FakeMatrixClient(),
matrixClient = matrixClient,
),
virtualItemFactory = TimelineItemVirtualFactory(
daySeparatorFactory = TimelineItemDaySeparatorFactory(