Allow polls to be edited (#1869)

Polls can be edited if they do not have any votes

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
jonnyandrew
2023-11-24 16:47:58 +00:00
committed by GitHub
parent 198147e813
commit 634d8167ea
50 changed files with 827 additions and 173 deletions

1
changelog.d/1869.feature Normal file
View File

@@ -0,0 +1 @@
Allow polls to be edited when they have not been voted on

View File

@@ -50,6 +50,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
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
@@ -113,6 +114,9 @@ class MessagesFlowNode @AssistedInject constructor(
@Parcelize
data object CreatePoll : NavTarget
@Parcelize
data class EditPoll(val eventId: EventId) : NavTarget
}
private val callback = plugins<MessagesEntryPoint.Callback>().firstOrNull()
@@ -157,6 +161,10 @@ class MessagesFlowNode @AssistedInject constructor(
backstack.push(NavTarget.CreatePoll)
}
override fun onEditPollClicked(eventId: EventId) {
backstack.push(NavTarget.EditPoll(eventId))
}
override fun onJoinCallClicked(roomId: RoomId) {
val inputs = CallType.RoomCall(
sessionId = matrixClient.sessionId,
@@ -204,7 +212,14 @@ class MessagesFlowNode @AssistedInject constructor(
sendLocationEntryPoint.createNode(this, buildContext)
}
NavTarget.CreatePoll -> {
createPollEntryPoint.createNode(this, buildContext)
createPollEntryPoint.nodeBuilder(this, buildContext)
.params(CreatePollEntryPoint.Params(mode = CreatePollMode.NewPoll))
.build()
}
is NavTarget.EditPoll -> {
createPollEntryPoint.nodeBuilder(this, buildContext)
.params(CreatePollEntryPoint.Params(mode = CreatePollMode.EditPoll(eventId = navTarget.eventId)))
.build()
}
}
}

View File

@@ -24,4 +24,5 @@ interface MessagesNavigator {
fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo)
fun onForwardEventClicked(eventId: EventId)
fun onReportContentClicked(eventId: EventId, senderId: UserId)
fun onEditPollClicked(eventId: EventId)
}

View File

@@ -66,6 +66,7 @@ class MessagesNode @AssistedInject constructor(
fun onReportMessage(eventId: EventId, senderId: UserId)
fun onSendLocationClicked()
fun onCreatePollClicked()
fun onEditPollClicked(eventId: EventId)
fun onJoinCallClicked(roomId: RoomId)
}
@@ -107,6 +108,10 @@ class MessagesNode @AssistedInject constructor(
callback?.onReportMessage(eventId, senderId)
}
override fun onEditPollClicked(eventId: EventId) {
callback?.onEditPollClicked(eventId)
}
private fun onSendLocationClicked() {
callback?.onSendLocationClicked()
}

View File

@@ -93,7 +93,7 @@ class MessagesPresenter @AssistedInject constructor(
private val room: MatrixRoom,
private val composerPresenter: MessageComposerPresenter,
private val voiceMessageComposerPresenter: VoiceMessageComposerPresenter,
private val timelinePresenter: TimelinePresenter,
timelinePresenterFactory: TimelinePresenter.Factory,
private val actionListPresenter: ActionListPresenter,
private val customReactionPresenter: CustomReactionPresenter,
private val reactionSummaryPresenter: ReactionSummaryPresenter,
@@ -110,6 +110,8 @@ class MessagesPresenter @AssistedInject constructor(
private val buildMeta: BuildMeta,
) : Presenter<MessagesState> {
private val timelinePresenter = timelinePresenterFactory.create(navigator = navigator)
@AssistedFactory
interface Factory {
fun create(navigator: MessagesNavigator): MessagesPresenter
@@ -294,20 +296,28 @@ class MessagesPresenter @AssistedInject constructor(
composerState: MessageComposerState,
enableTextFormatting: Boolean,
) {
val composerMode = MessageComposerMode.Edit(
targetEvent.eventId,
(targetEvent.content as? TimelineItemTextBasedContent)?.let {
if (enableTextFormatting) {
it.htmlBody ?: it.body
} else {
it.body
}
}.orEmpty(),
targetEvent.transactionId,
)
composerState.eventSink(
MessageComposerEvents.SetMode(composerMode)
)
when (targetEvent.content) {
is TimelineItemPollContent -> {
if (targetEvent.eventId == null) return
navigator.onEditPollClicked(targetEvent.eventId)
}
else -> {
val composerMode = MessageComposerMode.Edit(
targetEvent.eventId,
(targetEvent.content as? TimelineItemTextBasedContent)?.let {
if (enableTextFormatting) {
it.htmlBody ?: it.body
} else {
it.body
}
}.orEmpty(),
targetEvent.transactionId,
)
composerState.eventSink(
MessageComposerEvents.SetMode(composerMode)
)
}
}
}
private fun handleActionReply(targetEvent: TimelineItem.Event, composerState: MessageComposerState) {

View File

@@ -112,7 +112,10 @@ class ActionListPresenter @Inject constructor(
// Can only reply or forward messages already uploaded to the server
add(TimelineItemAction.Reply)
}
if (!timelineItem.content.isEnded && timelineItem.isRemote && isMineOrCanRedact) {
if (timelineItem.isRemote && timelineItem.isEditable) {
add(TimelineItemAction.Edit)
}
if (timelineItem.isRemote && !timelineItem.content.isEnded && isMineOrCanRedact) {
add(TimelineItemAction.EndPoll)
}
if (timelineItem.content.canBeCopied()) {

View File

@@ -30,4 +30,8 @@ sealed interface TimelineEvents {
data class PollEndClicked(
val pollStartId: EventId,
) : TimelineEvents
data class PollEditClicked(
val pollStartId: EventId,
) : TimelineEvents
}

View File

@@ -27,8 +27,12 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.TimelineItem
import io.element.android.features.messages.impl.timeline.session.SessionState
@@ -54,16 +58,16 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
private const val BACK_PAGINATION_EVENT_LIMIT = 20
private const val BACK_PAGINATION_PAGE_SIZE = 50
class TimelinePresenter @Inject constructor(
class TimelinePresenter @AssistedInject constructor(
private val timelineItemsFactory: TimelineItemsFactory,
private val room: MatrixRoom,
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,
@@ -71,6 +75,11 @@ class TimelinePresenter @Inject constructor(
private val redactedVoiceMessageManager: RedactedVoiceMessageManager,
) : Presenter<TimelineState> {
@AssistedFactory
interface Factory {
fun create(navigator: MessagesNavigator): TimelinePresenter
}
private val timeline = room.timeline
@Composable
@@ -135,6 +144,8 @@ class TimelinePresenter @Inject constructor(
)
analyticsService.capture(PollEnd())
}
is TimelineEvents.PollEditClicked ->
navigator.onEditPollClicked(event.pollStartId)
}
}

View File

@@ -118,6 +118,7 @@ internal fun aTimelineItemEvent(
eventId: EventId = EventId("\$" + Random.nextInt().toString()),
transactionId: TransactionId? = null,
isMine: Boolean = false,
isEditable: Boolean = false,
senderDisplayName: String = "Sender",
content: TimelineItemEventContent = aTimelineItemTextContent(),
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
@@ -139,6 +140,7 @@ internal fun aTimelineItemEvent(
readReceiptState = readReceiptState,
sentTime = "12:34",
isMine = isMine,
isEditable = isEditable,
senderDisplayName = senderDisplayName,
groupPosition = groupPosition,
localSendState = sendState,

View File

@@ -526,6 +526,7 @@ private fun MessageEventBubbleContent(
TimelineItemEventContentView(
content = event.content,
isMine = event.isMine,
isEditable = event.isEditable,
interactionSource = interactionSource,
onClick = onMessageClick,
onLongClick = onMessageLongClick,

View File

@@ -68,6 +68,7 @@ fun TimelineItemStateEventRow(
TimelineItemEventContentView(
content = event.content,
isMine = event.isMine,
isEditable = event.isEditable,
interactionSource = interactionSource,
onClick = onClick,
onLongClick = onLongClick,

View File

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

View File

@@ -32,6 +32,7 @@ import kotlinx.collections.immutable.toImmutableList
fun TimelineItemPollView(
content: TimelineItemPollContent,
isMine: Boolean,
isEditable: Boolean,
eventSink: (TimelineEvents) -> Unit,
modifier: Modifier = Modifier,
) {
@@ -43,15 +44,20 @@ fun TimelineItemPollView(
eventSink(TimelineEvents.PollEndClicked(pollStartId))
}
fun onPollEdit(pollStartId: EventId) {
eventSink(TimelineEvents.PollEditClicked(pollStartId))
}
PollContentView(
eventId = content.eventId,
question = content.question,
answerItems = content.answerItems.toImmutableList(),
pollKind = content.pollKind,
isPollEnded = content.isEnded,
isPollEditable = isEditable,
isMine = isMine,
onAnswerSelected = ::onAnswerSelected,
onPollEdit = {}, // TODO Polls: Wire up this callback once poll edit screen is done.
onPollEdit = ::onPollEdit,
onPollEnd = ::onPollEnd,
modifier = modifier,
)
@@ -64,6 +70,7 @@ internal fun TimelineItemPollViewPreview(@PreviewParameter(TimelineItemPollConte
TimelineItemPollView(
content = content,
isMine = false,
isEditable = false,
eventSink = {},
)
}
@@ -75,6 +82,7 @@ internal fun TimelineItemPollCreatorViewPreview(@PreviewParameter(TimelineItemPo
TimelineItemPollView(
content = content,
isMine = true,
isEditable = false,
eventSink = {},
)
}

View File

@@ -88,6 +88,7 @@ class TimelineItemEventFactory @Inject constructor(
senderAvatar = senderAvatarData,
content = contentFactory.create(currentTimelineItem.event),
isMine = currentTimelineItem.event.isOwn,
isEditable = currentTimelineItem.event.isEditable,
sentTime = sentTime,
groupPosition = groupPosition,
reactionsState = currentTimelineItem.computeReactionsState(),

View File

@@ -62,6 +62,7 @@ sealed interface TimelineItem {
val content: TimelineItemEventContent,
val sentTime: String = "",
val isMine: Boolean = false,
val isEditable: Boolean,
val groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
val reactionsState: TimelineItemReactions,
val readReceiptState: TimelineItemReadReceipts,

View File

@@ -18,10 +18,6 @@ package io.element.android.features.messages.impl.timeline.model.event
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.location.api.Location
import io.element.android.features.poll.api.PollAnswerItem
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.poll.PollKind
open class TimelineItemLocationContentProvider : PreviewParameterProvider<TimelineItemLocationContent> {
override val values: Sequence<TimelineItemLocationContent>
@@ -41,31 +37,3 @@ fun aTimelineItemLocationContent(description: String? = null) = TimelineItemLoca
description = description,
)
fun aTimelineItemPollContent(
isEnded: Boolean = false,
) = TimelineItemPollContent(
eventId = EventId("\$anEventId"),
question = "Some question?",
answerItems = listOf(
PollAnswerItem(
answer = PollAnswer("id_1", "Answer1"),
isSelected = false,
isEnabled = false,
isWinner = false,
isDisclosed = false,
votesCount = 0,
percentage = 0.0f,
),
PollAnswerItem(
answer = PollAnswer("id_2", "Answer2"),
isSelected = false,
isEnabled = false,
isWinner = false,
isDisclosed = false,
votesCount = 0,
percentage = 0.0f,
),
),
pollKind = PollKind.Disclosed,
isEnded = isEnded,
)

View File

@@ -17,7 +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.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.poll.PollKind
@@ -29,12 +31,16 @@ open class TimelineItemPollContentProvider : PreviewParameterProvider<TimelineIt
)
}
fun aTimelineItemPollContent(): TimelineItemPollContent {
fun aTimelineItemPollContent(
question: String = aPollQuestion(),
answerItems: List<PollAnswerItem> = aPollAnswerItemList(),
isEnded: Boolean = false,
): TimelineItemPollContent {
return TimelineItemPollContent(
eventId = EventId("\$anEventId"),
pollKind = PollKind.Disclosed,
question = "What type of food should we have at the party?",
answerItems = aPollAnswerItemList(),
isEnded = false,
question = question,
answerItems = answerItems,
isEnded = isEnded,
)
}

View File

@@ -30,6 +30,9 @@ class FakeMessagesNavigator : MessagesNavigator {
var onReportContentClickedCount = 0
private set
var onEditPollClickedCount = 0
private set
override fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
onShowEventDebugInfoClickedCount++
}
@@ -41,4 +44,8 @@ class FakeMessagesNavigator : MessagesNavigator {
override fun onReportContentClicked(eventId: EventId, senderId: UserId) {
onReportContentClickedCount++
}
override fun onEditPollClicked(eventId: EventId) {
onEditPollClickedCount++
}
}

View File

@@ -327,6 +327,20 @@ class MessagesPresenterTest {
}
}
@Test
fun `present - handle action edit poll`() = runTest {
val navigator = FakeMessagesNavigator()
val presenter = createMessagesPresenter(navigator = navigator)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Edit, aMessageEvent(content = aTimelineItemPollContent())))
assertThat(navigator.onEditPollClickedCount).isEqualTo(1)
}
}
@Test
fun `present - handle action redact`() = runTest {
val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
@@ -672,12 +686,18 @@ class MessagesPresenterTest {
room = matrixRoom,
dispatchers = coroutineDispatchers,
appScope = this,
navigator = navigator,
analyticsService = analyticsService,
encryptionService = FakeEncryptionService(),
verificationService = FakeSessionVerificationService(),
featureFlagService = FakeFeatureFlagService(),
redactedVoiceMessageManager = FakeRedactedVoiceMessageManager(),
)
val timelinePresenterFactory = object: TimelinePresenter.Factory {
override fun create(navigator: MessagesNavigator): TimelinePresenter {
return timelinePresenter
}
}
val preferencesStore = InMemoryPreferencesStore(isRichTextEditorEnabled = true)
val actionListPresenter = ActionListPresenter(preferencesStore = preferencesStore)
val readReceiptBottomSheetPresenter = ReadReceiptBottomSheetPresenter()
@@ -688,7 +708,7 @@ class MessagesPresenterTest {
room = matrixRoom,
composerPresenter = messageComposerPresenter,
voiceMessageComposerPresenter = voiceMessageComposerPresenter,
timelinePresenter = timelinePresenter,
timelinePresenterFactory = timelinePresenterFactory,
actionListPresenter = actionListPresenter,
customReactionPresenter = customReactionPresenter,
reactionSummaryPresenter = reactionSummaryPresenter,

View File

@@ -29,6 +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.libraries.featureflag.test.InMemoryPreferencesStore
import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.tests.testutils.WarmUpRule
@@ -407,7 +408,7 @@ class ActionListPresenterTest {
}
@Test
fun `present - compute for poll message`() = runTest {
fun `present - compute for editable poll message`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = false)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -415,7 +416,36 @@ class ActionListPresenterTest {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
content = aTimelineItemPollContent(),
isEditable = true,
content = aTimelineItemPollContent(answerItems = aPollAnswerItemList(hasVotes = false)),
)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
messageEvent,
persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Edit,
TimelineItemAction.EndPoll,
TimelineItemAction.Redact,
)
)
)
assertThat(successState.displayEmojiReactions).isTrue()
}
}
@Test
fun `present - compute for non-editable poll message`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = false)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
isEditable = false,
content = aTimelineItemPollContent(answerItems = aPollAnswerItemList(hasVotes = true)),
)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
val successState = awaitItem()
@@ -442,6 +472,7 @@ class ActionListPresenterTest {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
isEditable = false,
content = aTimelineItemPollContent(isEnded = true),
)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))

View File

@@ -38,6 +38,7 @@ import kotlinx.collections.immutable.toImmutableList
internal fun aMessageEvent(
eventId: EventId? = AN_EVENT_ID,
isMine: Boolean = true,
isEditable: Boolean = true,
content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false),
inReplyTo: InReplyToDetails? = null,
isThreaded: Boolean = false,
@@ -52,6 +53,7 @@ internal fun aMessageEvent(
content = content,
sentTime = "",
isMine = isMine,
isEditable = isEditable,
reactionsState = aTimelineItemReactions(count = 0),
readReceiptState = TimelineItemReadReceipts(emptyList<ReadReceiptData>().toImmutableList()),
localSendState = sendState,

View File

@@ -22,6 +22,7 @@ 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
@@ -223,10 +224,10 @@ class TimelinePresenterTest {
assertThat(initialState.hasNewItems).isFalse()
assertThat(initialState.timelineItems.size).isEqualTo(0)
val now = Date().time
val minuteInMilis = 60 * 1000
val minuteInMillis = 60 * 1000
// Use index as a convenient value for timestamp
val (alice, bob, charlie) = aMatrixUserList().take(3).mapIndexed { i, user ->
ReactionSender(senderId = user.userId, timestamp = now + i * minuteInMilis)
ReactionSender(senderId = user.userId, timestamp = now + i * minuteInMillis)
}
val oneReaction = listOf(
EventReaction(
@@ -312,6 +313,20 @@ class TimelinePresenterTest {
}
}
@Test
fun `present - PollEditClicked event navigates`() = runTest {
val navigator = FakeMessagesNavigator()
val presenter = createTimelinePresenter(
messagesNavigator = navigator,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().eventSink(TimelineEvents.PollEditClicked(AN_EVENT_ID))
assertThat(navigator.onEditPollClickedCount).isEqualTo(1)
}
}
@Test
fun `present - side effect on redacted items is invoked`() = runTest {
val redactedVoiceMessageManager = FakeRedactedVoiceMessageManager()
@@ -337,12 +352,14 @@ class TimelinePresenterTest {
timeline: MatrixTimeline = FakeMatrixTimeline(),
timelineItemsFactory: TimelineItemsFactory = aTimelineItemsFactory(),
redactedVoiceMessageManager: RedactedVoiceMessageManager = FakeRedactedVoiceMessageManager(),
messagesNavigator: FakeMessagesNavigator = FakeMessagesNavigator(),
): TimelinePresenter {
return TimelinePresenter(
timelineItemsFactory = timelineItemsFactory,
room = FakeMatrixRoom(matrixTimeline = timeline),
dispatchers = testCoroutineDispatchers(),
appScope = this,
navigator = messagesNavigator,
analyticsService = FakeAnalyticsService(),
encryptionService = FakeEncryptionService(),
verificationService = FakeSessionVerificationService(),
@@ -360,6 +377,7 @@ class TimelinePresenterTest {
room = room,
dispatchers = testCoroutineDispatchers(),
appScope = this,
navigator = FakeMessagesNavigator(),
analyticsService = analyticsService,
encryptionService = FakeEncryptionService(),
verificationService = FakeSessionVerificationService(),

View File

@@ -44,6 +44,7 @@ class TimelineItemGrouperTest {
reactionsState = aTimelineItemReactions(count = 0),
readReceiptState = TimelineItemReadReceipts(emptyList<ReadReceiptData>().toImmutableList()),
localSendState = LocalEventSendState.Sent(AN_EVENT_ID),
isEditable = false,
inReplyTo = null,
isThreaded = false,
debugInfo = aTimelineItemDebugInfo(),

View File

@@ -19,6 +19,8 @@ package io.element.android.features.poll.api
import io.element.android.libraries.matrix.api.poll.PollAnswer
import kotlinx.collections.immutable.persistentListOf
fun aPollQuestion() = "What type of food should we have at the party?"
fun aPollAnswerItemList(
hasVotes: Boolean = true,
isEnded: Boolean = false,
@@ -30,7 +32,7 @@ fun aPollAnswerItemList(
isEnabled = !isEnded,
isWinner = isEnded,
votesCount = if (hasVotes) 5 else 0,
percentage = 0.5f
percentage = if (hasVotes) 0.5f else 0f
),
aPollAnswerItem(
answer = PollAnswer("option_2", "Chinese \uD83C\uDDE8\uD83C\uDDF3"),
@@ -47,9 +49,14 @@ fun aPollAnswerItemList(
isWinner = false,
isSelected = true,
votesCount = if (hasVotes) 1 else 0,
percentage = 0.1f
percentage = if (hasVotes) 0.1f else 0f
),
aPollAnswerItem(
isDisclosed = isDisclosed,
isEnabled = !isEnded,
votesCount = if (hasVotes) 4 else 0,
percentage = if (hasVotes) 0.4f else 0f,
),
aPollAnswerItem(isDisclosed = isDisclosed, isEnabled = !isEnded),
)
fun aPollAnswerItem(

View File

@@ -55,6 +55,7 @@ fun PollContentView(
question: String,
answerItems: ImmutableList<PollAnswerItem>,
pollKind: PollKind,
isPollEditable: Boolean,
isPollEnded: Boolean,
isMine: Boolean,
onAnswerSelected: (pollStartId: EventId, answerId: String) -> Unit,
@@ -103,8 +104,8 @@ fun PollContentView(
if (isMine) {
CreatorView(
votesCount = 1, // TODO Polls: set to `votesCount` when edit poll screen is implemented.
isPollEnded = isPollEnded,
isPollEditable = isPollEditable,
onPollEdit = ::onPollEdit,
onPollEnd = { showConfirmation = true },
modifier = Modifier.fillMaxWidth(),
@@ -197,26 +198,25 @@ private fun ColumnScope.UndisclosedPollBottomNotice(
@Composable
private fun CreatorView(
@Suppress("SameParameterValue") votesCount: Int, // TODO Polls: remove @Suppress when edit poll screen is implemented.
isPollEnded: Boolean,
isPollEditable: Boolean,
onPollEdit: () -> Unit,
onPollEnd: () -> Unit,
modifier: Modifier = Modifier
) {
if (!isPollEnded) {
if (votesCount == 0) {
when {
isPollEditable ->
Button(
text = stringResource(id = CommonStrings.action_edit_poll),
onClick = onPollEdit,
modifier = modifier,
)
} else {
!isPollEnded ->
Button(
text = stringResource(id = CommonStrings.action_end_poll),
onClick = onPollEnd,
modifier = modifier,
)
}
}
}
@@ -229,6 +229,7 @@ internal fun PollContentUndisclosedPreview() = ElementPreview {
answerItems = aPollAnswerItemList(isDisclosed = false),
pollKind = PollKind.Undisclosed,
isPollEnded = false,
isPollEditable = false,
isMine = false,
onAnswerSelected = { _, _ -> },
onPollEdit = {},
@@ -245,6 +246,7 @@ internal fun PollContentDisclosedPreview() = ElementPreview {
answerItems = aPollAnswerItemList(),
pollKind = PollKind.Disclosed,
isPollEnded = false,
isPollEditable = false,
isMine = false,
onAnswerSelected = { _, _ -> },
onPollEdit = {},
@@ -261,6 +263,7 @@ internal fun PollContentEndedPreview() = ElementPreview {
answerItems = aPollAnswerItemList(isEnded = true),
pollKind = PollKind.Disclosed,
isPollEnded = true,
isPollEditable = false,
isMine = false,
onAnswerSelected = { _, _ -> },
onPollEdit = {},
@@ -270,13 +273,14 @@ internal fun PollContentEndedPreview() = ElementPreview {
@PreviewsDayNight
@Composable
internal fun PollContentCreatorNoVotesPreview() = ElementPreview {
internal fun PollContentCreatorEditablePreview() = ElementPreview {
PollContentView(
eventId = EventId("\$anEventId"),
question = "What type of food should we have at the party?",
answerItems = aPollAnswerItemList(hasVotes = false, isEnded = false),
pollKind = PollKind.Disclosed,
isPollEnded = false,
isPollEditable = true,
isMine = true,
onAnswerSelected = { _, _ -> },
onPollEdit = {},
@@ -293,6 +297,7 @@ internal fun PollContentCreatorPreview() = ElementPreview {
answerItems = aPollAnswerItemList(isEnded = false),
pollKind = PollKind.Disclosed,
isPollEnded = false,
isPollEditable = false,
isMine = true,
onAnswerSelected = { _, _ -> },
onPollEdit = {},
@@ -309,6 +314,7 @@ internal fun PollContentCreatorEndedPreview() = ElementPreview {
answerItems = aPollAnswerItemList(isEnded = true),
pollKind = PollKind.Disclosed,
isPollEnded = true,
isPollEditable = false,
isMine = true,
onAnswerSelected = { _, _ -> },
onPollEdit = {},

View File

@@ -21,5 +21,14 @@ import com.bumble.appyx.core.node.Node
import io.element.android.libraries.architecture.FeatureEntryPoint
interface CreatePollEntryPoint : FeatureEntryPoint {
fun createNode(parentNode: Node, buildContext: BuildContext): Node
data class Params(
val mode: CreatePollMode,
)
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
interface NodeBuilder {
fun params(params: Params): NodeBuilder
fun build(): Node
}
}

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.create
import io.element.android.libraries.matrix.api.core.EventId
sealed interface CreatePollMode {
data object NewPoll : CreatePollMode
data class EditPoll(val eventId: EventId) : CreatePollMode
}

View File

@@ -19,7 +19,7 @@ package io.element.android.features.poll.impl.create
import io.element.android.libraries.matrix.api.poll.PollKind
sealed interface CreatePollEvents {
data object Create : CreatePollEvents
data object Save : CreatePollEvents
data class SetQuestion(val question: String) : CreatePollEvents
data class SetAnswer(val index: Int, val text: String) : CreatePollEvents
data object AddAnswer : CreatePollEvents

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.create
internal sealed class CreatePollException : Exception() {
data class GetPollFailed(
override val message: String?, override val cause: Throwable?
) : CreatePollException()
data class SavePollFailed(
override val message: String?, override val cause: Throwable?
) : CreatePollException()
}

View File

@@ -26,6 +26,9 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.poll.api.create.CreatePollMode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.RoomScope
import io.element.android.services.analytics.api.AnalyticsService
@@ -37,7 +40,11 @@ class CreatePollNode @AssistedInject constructor(
analyticsService: AnalyticsService,
) : Node(buildContext, plugins = plugins) {
private val presenter = presenterFactory.create(backNavigator = ::navigateUp)
data class Inputs(val mode: CreatePollMode) : NodeInputs
private val inputs: Inputs = inputs()
private val presenter = presenterFactory.create(backNavigator = ::navigateUp, mode = inputs.mode)
init {
lifecycle.subscribe(

View File

@@ -17,6 +17,7 @@
package io.element.android.features.poll.impl.create
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -32,9 +33,11 @@ import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.Composer
import im.vector.app.features.analytics.plan.PollCreation
import io.element.android.features.messages.api.MessageComposerContext
import io.element.android.features.poll.api.create.CreatePollMode
import io.element.android.features.poll.impl.data.PollRepository
import io.element.android.libraries.architecture.Presenter
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.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -47,26 +50,39 @@ private const val MAX_ANSWER_LENGTH = 240
private const val MAX_SELECTIONS = 1
class CreatePollPresenter @AssistedInject constructor(
private val room: MatrixRoom,
private val repository: PollRepository,
private val analyticsService: AnalyticsService,
private val messageComposerContext: MessageComposerContext,
@Assisted private val navigateUp: () -> Unit,
@Assisted private val mode: CreatePollMode,
) : Presenter<CreatePollState> {
@AssistedFactory
interface Factory {
fun create(backNavigator: () -> Unit): CreatePollPresenter
fun create(backNavigator: () -> Unit, mode: CreatePollMode): CreatePollPresenter
}
@Composable
override fun present(): CreatePollState {
var question: String by rememberSaveable { mutableStateOf("") }
var answers: List<String> by rememberSaveable() { mutableStateOf(listOf("", "")) }
var answers: List<String> by rememberSaveable { mutableStateOf(listOf("", "")) }
var pollKind: PollKind by rememberSaveable(saver = pollKindSaver) { mutableStateOf(PollKind.Disclosed) }
var showConfirmation: Boolean by rememberSaveable { mutableStateOf(false) }
val canCreate: Boolean by remember { derivedStateOf { canCreate(question, answers) } }
LaunchedEffect(Unit) {
if (mode is CreatePollMode.EditPoll) {
repository.getPoll(mode.eventId).onSuccess {
question = it.question
answers = it.answers.map(PollAnswer::text)
pollKind = it.kind
}.onFailure {
analyticsService.trackGetPollFailed(it)
navigateUp()
}
}
}
val canSave: Boolean by remember { derivedStateOf { canSave(question, answers) } }
val canAddAnswer: Boolean by remember { derivedStateOf { canAddAnswer(answers) } }
val immutableAnswers: ImmutableList<Answer> by remember { derivedStateOf { answers.toAnswers() } }
@@ -74,29 +90,25 @@ class CreatePollPresenter @AssistedInject constructor(
fun handleEvents(event: CreatePollEvents) {
when (event) {
is CreatePollEvents.Create -> scope.launch {
if (canCreate) {
room.createPoll(
is CreatePollEvents.Save -> scope.launch {
if (canSave) {
repository.savePoll(
existingPollId = when (mode) {
is CreatePollMode.EditPoll -> mode.eventId
is CreatePollMode.NewPoll -> null
},
question = question,
answers = answers,
maxSelections = MAX_SELECTIONS,
pollKind = pollKind,
)
analyticsService.capture(
Composer(
inThread = messageComposerContext.composerMode.inThread,
isEditing = messageComposerContext.composerMode.isEditing,
isReply = messageComposerContext.composerMode.isReply,
messageType = Composer.MessageType.Poll,
)
)
analyticsService.capture(
PollCreation(
action = PollCreation.Action.Create,
maxSelections = MAX_SELECTIONS,
).onSuccess {
analyticsService.capturePollSaved(
isUndisclosed = pollKind == PollKind.Undisclosed,
numberOfAnswers = answers.size,
)
)
}.onFailure {
analyticsService.trackSavePollFailed(it, mode)
}
navigateUp()
} else {
Timber.d("Cannot create poll")
@@ -135,7 +147,11 @@ class CreatePollPresenter @AssistedInject constructor(
}
return CreatePollState(
canCreate = canCreate,
mode = when (mode) {
is CreatePollMode.NewPoll -> CreatePollState.Mode.New
is CreatePollMode.EditPoll -> CreatePollState.Mode.Edit
},
canSave = canSave,
canAddAnswer = canAddAnswer,
question = question,
answers = immutableAnswers,
@@ -144,16 +160,61 @@ class CreatePollPresenter @AssistedInject constructor(
eventSink = ::handleEvents,
)
}
private fun AnalyticsService.capturePollSaved(
isUndisclosed: Boolean,
numberOfAnswers: Int,
) {
capture(
Composer(
inThread = messageComposerContext.composerMode.inThread,
isEditing = mode is CreatePollMode.EditPoll,
isReply = messageComposerContext.composerMode.isReply,
messageType = Composer.MessageType.Poll,
)
)
capture(
PollCreation(
action = when (mode) {
is CreatePollMode.EditPoll -> PollCreation.Action.Edit
is CreatePollMode.NewPoll -> PollCreation.Action.Create
},
isUndisclosed = isUndisclosed,
numberOfAnswers = numberOfAnswers,
)
)
}
}
private fun canCreate(
private fun AnalyticsService.trackGetPollFailed(cause: Throwable) {
val exception = CreatePollException.GetPollFailed(
message = "Tried to edit poll but couldn't get poll",
cause = cause,
)
Timber.e(exception)
trackError(exception)
}
private fun AnalyticsService.trackSavePollFailed(cause: Throwable, mode: CreatePollMode) {
val exception = CreatePollException.SavePollFailed(
message = when (mode) {
CreatePollMode.NewPoll -> "Failed to create poll"
is CreatePollMode.EditPoll -> "Failed to edit poll"
},
cause = cause,
)
Timber.e(exception)
trackError(exception)
}
private fun canSave(
question: String,
answers: List<String>
) = question.isNotBlank() && answers.size >= MIN_ANSWERS && answers.all { it.isNotBlank() }
private fun canAddAnswer(answers: List<String>) = answers.size < MAX_ANSWERS
private fun List<String>.toAnswers(): ImmutableList<Answer> {
fun List<String>.toAnswers(): ImmutableList<Answer> {
return map { answer ->
Answer(
text = answer,

View File

@@ -20,14 +20,20 @@ import io.element.android.libraries.matrix.api.poll.PollKind
import kotlinx.collections.immutable.ImmutableList
data class CreatePollState(
val canCreate: Boolean,
val mode: Mode,
val canSave: Boolean,
val canAddAnswer: Boolean,
val question: String,
val answers: ImmutableList<Answer>,
val pollKind: PollKind,
val showConfirmation: Boolean,
val eventSink: (CreatePollEvents) -> Unit,
)
) {
enum class Mode {
New,
Edit,
}
}
data class Answer(
val text: String,

View File

@@ -25,6 +25,7 @@ class CreatePollStateProvider : PreviewParameterProvider<CreatePollState> {
override val values: Sequence<CreatePollState>
get() = sequenceOf(
aCreatePollState(
mode = CreatePollState.Mode.New,
canCreate = false,
canAddAnswer = true,
question = "",
@@ -36,6 +37,7 @@ class CreatePollStateProvider : PreviewParameterProvider<CreatePollState> {
showConfirmation = false,
),
aCreatePollState(
mode = CreatePollState.Mode.New,
canCreate = true,
canAddAnswer = true,
question = "What type of food should we have?",
@@ -47,6 +49,7 @@ class CreatePollStateProvider : PreviewParameterProvider<CreatePollState> {
pollKind = PollKind.Undisclosed,
),
aCreatePollState(
mode = CreatePollState.Mode.New,
canCreate = true,
canAddAnswer = true,
question = "What type of food should we have?",
@@ -58,6 +61,7 @@ class CreatePollStateProvider : PreviewParameterProvider<CreatePollState> {
pollKind = PollKind.Undisclosed,
),
aCreatePollState(
mode = CreatePollState.Mode.New,
canCreate = true,
canAddAnswer = true,
question = "What type of food should we have?",
@@ -71,6 +75,7 @@ class CreatePollStateProvider : PreviewParameterProvider<CreatePollState> {
pollKind = PollKind.Undisclosed,
),
aCreatePollState(
mode = CreatePollState.Mode.New,
canCreate = true,
canAddAnswer = false,
question = "Should there be more than 20 answers?",
@@ -100,6 +105,7 @@ class CreatePollStateProvider : PreviewParameterProvider<CreatePollState> {
pollKind = PollKind.Undisclosed,
),
aCreatePollState(
mode = CreatePollState.Mode.New,
canCreate = true,
canAddAnswer = true,
question = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." +
@@ -120,11 +126,24 @@ class CreatePollStateProvider : PreviewParameterProvider<CreatePollState> {
),
showConfirmation = false,
pollKind = PollKind.Undisclosed,
)
),
aCreatePollState(
mode = CreatePollState.Mode.Edit,
canCreate = false,
canAddAnswer = true,
question = "",
answers = persistentListOf(
Answer("", false),
Answer("", false)
),
pollKind = PollKind.Disclosed,
showConfirmation = false,
),
)
}
private fun aCreatePollState(
mode: CreatePollState.Mode,
canCreate: Boolean,
canAddAnswer: Boolean,
question: String,
@@ -133,7 +152,8 @@ private fun aCreatePollState(
pollKind: PollKind
): CreatePollState {
return CreatePollState(
canCreate = canCreate,
mode = mode,
canSave = canCreate,
canAddAnswer = canAddAnswer,
question = question,
answers = answers,

View File

@@ -47,8 +47,8 @@ import io.element.android.features.poll.impl.R
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
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.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon
@@ -67,7 +67,6 @@ import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CreatePollView(
state: CreatePollState,
@@ -90,23 +89,11 @@ fun CreatePollView(
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(id = R.string.screen_create_poll_title),
style = ElementTheme.typography.aliasScreenTitle,
)
},
navigationIcon = {
BackButton(onClick = navBack)
},
actions = {
TextButton(
text = stringResource(id = CommonStrings.action_create),
onClick = { state.eventSink(CreatePollEvents.Create) },
enabled = state.canCreate,
)
}
CreatePollTopAppBar(
mode = state.mode,
saveEnabled = state.canSave,
onBackPress = navBack,
onSaveClicked = { state.eventSink(CreatePollEvents.Save) }
)
},
) { paddingValues ->
@@ -210,6 +197,40 @@ fun CreatePollView(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CreatePollTopAppBar(
mode: CreatePollState.Mode,
saveEnabled: Boolean,
onBackPress: () -> Unit = {},
onSaveClicked: () -> Unit = {},
) {
TopAppBar(
title = {
Text(
text = when (mode) {
CreatePollState.Mode.New -> stringResource(id = R.string.screen_create_poll_title)
CreatePollState.Mode.Edit -> stringResource(id = R.string.screen_edit_poll_title)
},
style = ElementTheme.typography.aliasScreenTitle,
)
},
navigationIcon = {
BackButton(onClick = onBackPress)
},
actions = {
TextButton(
text = when (mode) {
CreatePollState.Mode.New -> stringResource(id = CommonStrings.action_create)
CreatePollState.Mode.Edit -> stringResource(id = CommonStrings.action_done)
},
onClick = onSaveClicked,
enabled = saveEnabled,
)
}
)
}
@PreviewsDayNight
@Composable
internal fun CreatePollViewPreview(

View File

@@ -18,6 +18,7 @@ package io.element.android.features.poll.impl.create
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.poll.api.create.CreatePollEntryPoint
import io.element.android.libraries.architecture.createNode
@@ -26,7 +27,19 @@ import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultCreatePollEntryPoint @Inject constructor() : CreatePollEntryPoint {
override fun createNode(parentNode: Node, buildContext: BuildContext): Node {
return parentNode.createNode<CreatePollNode>(buildContext)
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): CreatePollEntryPoint.NodeBuilder {
val plugins = ArrayList<Plugin>()
return object : CreatePollEntryPoint.NodeBuilder {
override fun params(params: CreatePollEntryPoint.Params): CreatePollEntryPoint.NodeBuilder {
plugins += CreatePollNode.Inputs(mode = params.mode)
return this
}
override fun build(): Node {
return parentNode.createNode<CreatePollNode>(buildContext, plugins)
}
}
}
}

View File

@@ -0,0 +1,62 @@
/*
* 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.data
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import kotlinx.coroutines.flow.first
import javax.inject.Inject
class PollRepository @Inject constructor(
private val room: MatrixRoom,
) {
suspend fun getPoll(eventId: EventId): Result<PollContent> = runCatching {
room.timeline
.timelineItems
.first()
.asSequence()
.filterIsInstance<MatrixTimelineItem.Event>()
.first { it.eventId == eventId }
.event
.content as PollContent
}
suspend fun savePoll(
existingPollId: EventId?,
question: String,
answers: List<String>,
pollKind: PollKind,
maxSelections: Int,
): Result<Unit> = when (existingPollId) {
null -> room.createPoll(
question = question,
answers = answers,
maxSelections = maxSelections,
pollKind = pollKind,
)
else -> room.editPoll(
pollStartId = existingPollId,
question = question,
answers = answers,
maxSelections = maxSelections,
pollKind = pollKind,
)
}
}

View File

@@ -9,4 +9,5 @@
<string name="screen_create_poll_question_desc">"Question or topic"</string>
<string name="screen_create_poll_question_hint">"What is the poll about?"</string>
<string name="screen_create_poll_title">"Create Poll"</string>
<string name="screen_edit_poll_title">"Edit poll"</string>
</resources>

View File

@@ -18,14 +18,25 @@ package io.element.android.features.poll.impl.create
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.TurbineTestContext
import app.cash.turbine.test
import com.google.common.truth.Truth
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.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.test.room.CreatePollInvocation
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.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.delay
@@ -38,58 +49,73 @@ class CreatePollPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
private val pollEventId = AN_EVENT_ID
private var navUpInvocationsCount = 0
private val fakeMatrixRoom = FakeMatrixRoom()
private val existingPoll = anExistingPoll()
private val fakeMatrixRoom = createFakeMatrixRoom(existingPoll)
private val fakeAnalyticsService = FakeAnalyticsService()
private val fakeMessageComposerContext = FakeMessageComposerContext()
private val presenter = CreatePollPresenter(
room = fakeMatrixRoom,
analyticsService = fakeAnalyticsService,
messageComposerContext = fakeMessageComposerContext,
navigateUp = { navUpInvocationsCount++ },
)
@Test
fun `default state has proper default values`() = runTest {
val presenter = createCreatePollPresenter(mode = CreatePollMode.NewPoll)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().let {
Truth.assertThat(it.canCreate).isEqualTo(false)
Truth.assertThat(it.canAddAnswer).isEqualTo(true)
Truth.assertThat(it.question).isEqualTo("")
Truth.assertThat(it.answers).isEqualTo(listOf(Answer("", false), Answer("", false)))
Truth.assertThat(it.pollKind).isEqualTo(PollKind.Disclosed)
Truth.assertThat(it.showConfirmation).isEqualTo(false)
}
awaitDefaultItem()
}
}
@Test
fun `in edit mode, poll values are loaded`() = runTest {
val presenter = createCreatePollPresenter(mode = CreatePollMode.EditPoll(AN_EVENT_ID))
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitDefaultItem()
awaitPollLoaded()
}
}
@Test
fun `in edit mode, if poll doesn't exist, error is tracked and screen is closed`() = runTest {
val room = createFakeMatrixRoom(existingPoll = null)
val presenter = createCreatePollPresenter(mode = CreatePollMode.EditPoll(AN_EVENT_ID), room = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitDefaultItem()
Truth.assertThat(fakeAnalyticsService.trackedErrors.filterIsInstance<CreatePollException.GetPollFailed>()).isNotEmpty()
Truth.assertThat(navUpInvocationsCount).isEqualTo(1)
}
}
@Test
fun `non blank question and 2 answers are required to create a poll`() = runTest {
val presenter = createCreatePollPresenter(mode = CreatePollMode.NewPoll)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initial = awaitItem()
Truth.assertThat(initial.canCreate).isEqualTo(false)
Truth.assertThat(initial.canSave).isFalse()
initial.eventSink(CreatePollEvents.SetQuestion("A question?"))
val questionSet = awaitItem()
Truth.assertThat(questionSet.canCreate).isEqualTo(false)
Truth.assertThat(questionSet.canSave).isFalse()
questionSet.eventSink(CreatePollEvents.SetAnswer(0, "Answer 1"))
val answer1Set = awaitItem()
Truth.assertThat(answer1Set.canCreate).isEqualTo(false)
Truth.assertThat(answer1Set.canSave).isFalse()
answer1Set.eventSink(CreatePollEvents.SetAnswer(1, "Answer 2"))
val answer2Set = awaitItem()
Truth.assertThat(answer2Set.canCreate).isEqualTo(true)
Truth.assertThat(answer2Set.canSave).isTrue()
}
}
@Test
fun `create polls sends a poll start event`() = runTest {
fun `create poll sends a poll start event`() = runTest {
val presenter = createCreatePollPresenter(mode = CreatePollMode.NewPoll)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -98,11 +124,11 @@ class CreatePollPresenterTest {
initial.eventSink(CreatePollEvents.SetAnswer(0, "Answer 1"))
initial.eventSink(CreatePollEvents.SetAnswer(1, "Answer 2"))
skipItems(3)
initial.eventSink(CreatePollEvents.Create)
initial.eventSink(CreatePollEvents.Save)
delay(1) // Wait for the coroutine to finish
Truth.assertThat(fakeMatrixRoom.createPollInvocations.size).isEqualTo(1)
Truth.assertThat(fakeMatrixRoom.createPollInvocations.last()).isEqualTo(
CreatePollInvocation(
SavePollInvocation(
question = "A question?",
answers = listOf("Answer 1", "Answer 2"),
maxSelections = 1,
@@ -128,8 +154,104 @@ class CreatePollPresenterTest {
}
}
@Test
fun `when poll creation fails, error is tracked`() = runTest {
val error = Exception("cause")
fakeMatrixRoom.givenCreatePollResult(Result.failure(error))
val presenter = createCreatePollPresenter(mode = CreatePollMode.NewPoll)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitDefaultItem().eventSink(CreatePollEvents.SetQuestion("A question?"))
awaitItem().eventSink(CreatePollEvents.SetAnswer(0, "Answer 1"))
awaitItem().eventSink(CreatePollEvents.SetAnswer(1, "Answer 2"))
awaitItem().eventSink(CreatePollEvents.Save)
delay(1) // Wait for the coroutine to finish
Truth.assertThat(fakeMatrixRoom.createPollInvocations).hasSize(1)
Truth.assertThat(fakeAnalyticsService.capturedEvents).isEmpty()
Truth.assertThat(fakeAnalyticsService.trackedErrors).hasSize(1)
Truth.assertThat(fakeAnalyticsService.trackedErrors).containsExactly(
CreatePollException.SavePollFailed("Failed to create poll", error)
)
}
}
@Test
fun `edit poll sends a poll edit event`() = runTest {
val presenter = createCreatePollPresenter(mode = CreatePollMode.EditPoll(pollEventId))
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitDefaultItem()
awaitPollLoaded().apply {
eventSink(CreatePollEvents.SetQuestion("Changed question"))
}
awaitItem().apply {
eventSink(CreatePollEvents.SetAnswer(0, "Changed answer 1"))
}
awaitItem().apply {
eventSink(CreatePollEvents.SetAnswer(1, "Changed answer 2"))
}
awaitPollLoaded(
newQuestion = "Changed question",
newAnswer1 = "Changed answer 1",
newAnswer2 = "Changed answer 2",
).apply {
eventSink(CreatePollEvents.Save)
}
delay(1) // Wait for the coroutine to finish
Truth.assertThat(fakeMatrixRoom.editPollInvocations.size).isEqualTo(1)
Truth.assertThat(fakeMatrixRoom.editPollInvocations.last()).isEqualTo(
SavePollInvocation(
question = "Changed question",
answers = listOf("Changed answer 1", "Changed answer 2", "Maybe"),
maxSelections = 1,
pollKind = PollKind.Disclosed
)
)
Truth.assertThat(fakeAnalyticsService.capturedEvents.size).isEqualTo(2)
Truth.assertThat(fakeAnalyticsService.capturedEvents[0]).isEqualTo(
Composer(
inThread = false,
isEditing = true,
isReply = false,
messageType = Composer.MessageType.Poll,
)
)
Truth.assertThat(fakeAnalyticsService.capturedEvents[1]).isEqualTo(
PollCreation(
action = PollCreation.Action.Edit,
isUndisclosed = false,
numberOfAnswers = 3,
)
)
}
}
@Test
fun `when edit poll fails, error is tracked`() = runTest {
val error = Exception("cause")
fakeMatrixRoom.givenEditPollResult(Result.failure(error))
val presenter = createCreatePollPresenter(mode = CreatePollMode.EditPoll(pollEventId))
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitDefaultItem()
awaitPollLoaded().eventSink(CreatePollEvents.SetAnswer(0, "A"))
awaitPollLoaded(newAnswer1 = "A").eventSink(CreatePollEvents.Save)
delay(1) // Wait for the coroutine to finish
Truth.assertThat(fakeMatrixRoom.editPollInvocations).hasSize(1)
Truth.assertThat(fakeAnalyticsService.capturedEvents).isEmpty()
Truth.assertThat(fakeAnalyticsService.trackedErrors).hasSize(1)
Truth.assertThat(fakeAnalyticsService.trackedErrors).containsExactly(
CreatePollException.SavePollFailed("Failed to edit poll", error)
)
}
}
@Test
fun `add answer button adds an empty answer and removing it removes it`() = runTest {
val presenter = createCreatePollPresenter(mode = CreatePollMode.NewPoll)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -139,7 +261,7 @@ class CreatePollPresenterTest {
initial.eventSink(CreatePollEvents.AddAnswer)
val answerAdded = awaitItem()
Truth.assertThat(answerAdded.answers.size).isEqualTo(3)
Truth.assertThat(answerAdded.answers[2].text).isEqualTo("")
Truth.assertThat(answerAdded.answers[2].text).isEmpty()
initial.eventSink(CreatePollEvents.RemoveAnswer(2))
val answerRemoved = awaitItem()
@@ -149,6 +271,7 @@ class CreatePollPresenterTest {
@Test
fun `set question sets the question`() = runTest {
val presenter = createCreatePollPresenter(mode = CreatePollMode.NewPoll)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -161,6 +284,7 @@ class CreatePollPresenterTest {
@Test
fun `set poll answer sets the given poll answer`() = runTest {
val presenter = createCreatePollPresenter(mode = CreatePollMode.NewPoll)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -173,6 +297,7 @@ class CreatePollPresenterTest {
@Test
fun `set poll kind sets the poll kind`() = runTest {
val presenter = createCreatePollPresenter(mode = CreatePollMode.NewPoll)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -185,34 +310,37 @@ class CreatePollPresenterTest {
@Test
fun `can add options when between 2 and 20 and then no more`() = runTest {
val presenter = createCreatePollPresenter(mode = CreatePollMode.NewPoll)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initial = awaitItem()
Truth.assertThat(initial.canAddAnswer).isEqualTo(true)
Truth.assertThat(initial.canAddAnswer).isTrue()
repeat(17) {
initial.eventSink(CreatePollEvents.AddAnswer)
Truth.assertThat(awaitItem().canAddAnswer).isEqualTo(true)
Truth.assertThat(awaitItem().canAddAnswer).isTrue()
}
initial.eventSink(CreatePollEvents.AddAnswer)
Truth.assertThat(awaitItem().canAddAnswer).isEqualTo(false)
Truth.assertThat(awaitItem().canAddAnswer).isFalse()
}
}
@Test
fun `can delete option if there are more than 2`() = runTest {
val presenter = createCreatePollPresenter(mode = CreatePollMode.NewPoll)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initial = awaitItem()
Truth.assertThat(initial.answers.all { it.canDelete }).isEqualTo(false)
Truth.assertThat(initial.answers.all { it.canDelete }).isFalse()
initial.eventSink(CreatePollEvents.AddAnswer)
Truth.assertThat(awaitItem().answers.all { it.canDelete }).isEqualTo(true)
Truth.assertThat(awaitItem().answers.all { it.canDelete }).isTrue()
}
}
@Test
fun `option with more than 240 char is truncated`() = runTest {
val presenter = createCreatePollPresenter(mode = CreatePollMode.NewPoll)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -224,6 +352,7 @@ class CreatePollPresenterTest {
@Test
fun `navBack event calls navBack lambda`() = runTest {
val presenter = createCreatePollPresenter(mode = CreatePollMode.NewPoll)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -236,31 +365,104 @@ class CreatePollPresenterTest {
@Test
fun `confirm nav back with blank fields calls nav back lambda`() = runTest {
val presenter = createCreatePollPresenter(mode = CreatePollMode.NewPoll)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initial = awaitItem()
Truth.assertThat(navUpInvocationsCount).isEqualTo(0)
Truth.assertThat(initial.showConfirmation).isEqualTo(false)
Truth.assertThat(initial.showConfirmation).isFalse()
initial.eventSink(CreatePollEvents.ConfirmNavBack)
Truth.assertThat(navUpInvocationsCount).isEqualTo(1)
}
}
@Test
fun `confirm nav back with non blank fields shows confirmation dialog and sending hide hids it`() = runTest {
fun `confirm nav back with non blank fields shows confirmation dialog and sending hides it`() = runTest {
val presenter = createCreatePollPresenter(mode = CreatePollMode.NewPoll)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initial = awaitItem()
initial.eventSink(CreatePollEvents.SetQuestion("Non blank"))
Truth.assertThat(navUpInvocationsCount).isEqualTo(0)
Truth.assertThat(awaitItem().showConfirmation).isEqualTo(false)
Truth.assertThat(awaitItem().showConfirmation).isFalse()
initial.eventSink(CreatePollEvents.ConfirmNavBack)
Truth.assertThat(navUpInvocationsCount).isEqualTo(0)
Truth.assertThat(awaitItem().showConfirmation).isEqualTo(true)
Truth.assertThat(awaitItem().showConfirmation).isTrue()
initial.eventSink(CreatePollEvents.HideConfirmation)
Truth.assertThat(awaitItem().showConfirmation).isEqualTo(false)
Truth.assertThat(awaitItem().showConfirmation).isFalse()
}
}
private suspend fun TurbineTestContext<CreatePollState>.awaitDefaultItem() =
awaitItem().apply {
Truth.assertThat(canSave).isFalse()
Truth.assertThat(canAddAnswer).isTrue()
Truth.assertThat(question).isEmpty()
Truth.assertThat(answers).isEqualTo(listOf(Answer("", false), Answer("", false)))
Truth.assertThat(pollKind).isEqualTo(PollKind.Disclosed)
Truth.assertThat(showConfirmation).isFalse()
}
private suspend fun TurbineTestContext<CreatePollState>.awaitPollLoaded(
newQuestion: String? = null,
newAnswer1: String? = null,
newAnswer2: String? = null,
) =
awaitItem().apply {
Truth.assertThat(canSave).isTrue()
Truth.assertThat(canAddAnswer).isTrue()
Truth.assertThat(question).isEqualTo(newQuestion ?: existingPoll.question)
Truth.assertThat(answers).isEqualTo(existingPoll.expectedAnswersState().toMutableList().apply {
newAnswer1?.let { this[0] = Answer(it, true) }
newAnswer2?.let { this[1] = Answer(it, true) }
})
Truth.assertThat(pollKind).isEqualTo(existingPoll.kind)
}
private fun createCreatePollPresenter(
mode: CreatePollMode = CreatePollMode.NewPoll,
room: MatrixRoom = fakeMatrixRoom,
): CreatePollPresenter = CreatePollPresenter(
repository = PollRepository(room),
analyticsService = fakeAnalyticsService,
messageComposerContext = fakeMessageComposerContext,
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 = listOf(
PollAnswer("1", "Yes"),
PollAnswer("2", "No"),
PollAnswer("2", "Maybe"),
),
)
private fun PollContent.expectedAnswersState() = answers.map { answer ->
Answer(
text = answer.text,
canDelete = answers.size > 2,
)
}

View File

@@ -176,6 +176,23 @@ interface MatrixRoom : Closeable {
pollKind: PollKind,
): Result<Unit>
/**
* Edit a poll in the room.
*
* @param pollStartId The event ID of the poll start event.
* @param question The question to ask.
* @param answers The list of answers.
* @param maxSelections The maximum number of answers that can be selected.
* @param pollKind The kind of poll to create.
*/
suspend fun editPoll(
pollStartId: EventId,
question: String,
answers: List<String>,
maxSelections: Int,
pollKind: PollKind,
): Result<Unit>
/**
* Send a response to a poll.
*

View File

@@ -477,6 +477,30 @@ class RustMatrixRoom(
}
}
override suspend fun editPoll(
pollStartId: EventId,
question: String,
answers: List<String>,
maxSelections: Int,
pollKind: PollKind,
): Result<Unit> = withContext(roomDispatcher) {
runCatching {
val pollStartEvent =
innerRoom.getEventTimelineItemByEventId(
eventId = pollStartId.value
)
pollStartEvent.use {
innerRoom.editPoll(
question = question,
answers = answers,
maxSelections = maxSelections.toUByte(),
pollKind = pollKind.toInner(),
editItem = pollStartEvent,
)
}
}
}
override suspend fun sendPollResponse(
pollStartId: EventId,
answers: List<String>

View File

@@ -103,6 +103,7 @@ class FakeMatrixRoom(
private var reportContentResult = Result.success(Unit)
private var sendLocationResult = Result.success(Unit)
private var createPollResult = Result.success(Unit)
private var editPollResult = Result.success(Unit)
private var sendPollResponseResult = Result.success(Unit)
private var endPollResult = Result.success(Unit)
private var progressCallbackValues = emptyList<Pair<Long, Long>>()
@@ -130,8 +131,11 @@ class FakeMatrixRoom(
private val _sentLocations = mutableListOf<SendLocationInvocation>()
val sentLocations: List<SendLocationInvocation> = _sentLocations
private val _createPollInvocations = mutableListOf<CreatePollInvocation>()
val createPollInvocations: List<CreatePollInvocation> = _createPollInvocations
private val _createPollInvocations = mutableListOf<SavePollInvocation>()
val createPollInvocations: List<SavePollInvocation> = _createPollInvocations
private val _editPollInvocations = mutableListOf<SavePollInvocation>()
val editPollInvocations: List<SavePollInvocation> = _editPollInvocations
private val _sendPollResponseInvocations = mutableListOf<SendPollResponseInvocation>()
val sendPollResponseInvocations: List<SendPollResponseInvocation> = _sendPollResponseInvocations
@@ -375,10 +379,21 @@ class FakeMatrixRoom(
maxSelections: Int,
pollKind: PollKind
): Result<Unit> = simulateLongTask {
_createPollInvocations.add(CreatePollInvocation(question, answers, maxSelections, pollKind))
_createPollInvocations.add(SavePollInvocation(question, answers, maxSelections, pollKind))
return createPollResult
}
override suspend fun editPoll(
pollStartId: EventId,
question: String,
answers: List<String>,
maxSelections: Int,
pollKind: PollKind
): Result<Unit> = simulateLongTask {
_editPollInvocations.add(SavePollInvocation(question, answers, maxSelections, pollKind))
return editPollResult
}
override suspend fun sendPollResponse(
pollStartId: EventId,
answers: List<String>
@@ -511,6 +526,10 @@ class FakeMatrixRoom(
createPollResult = result
}
fun givenEditPollResult(result: Result<Unit>) {
editPollResult = result
}
fun givenSendPollResponseResult(result: Result<Unit>) {
sendPollResponseResult = result
}
@@ -544,7 +563,7 @@ data class SendLocationInvocation(
val assetType: AssetType?,
)
data class CreatePollInvocation(
data class SavePollInvocation(
val question: String,
val answers: List<String>,
val maxSelections: Int,

View File

@@ -181,11 +181,12 @@ fun aTimelineItemDebugInfo(
fun aPollContent(
question: String = "Do you like polls?",
answers: List<PollAnswer> = listOf(PollAnswer("1", "Yes"), PollAnswer("2", "No")),
) = PollContent(
question = question,
kind = PollKind.Disclosed,
maxSelections = 1u,
answers = listOf(PollAnswer("1", "Yes"), PollAnswer("2", "No")),
answers = answers,
votes = mapOf(),
endTime = null
)

View File

@@ -39,9 +39,11 @@
<string name="action_create">"Create"</string>
<string name="action_create_a_room">"Create a room"</string>
<string name="action_decline">"Decline"</string>
<string name="action_delete_poll">"Delete Poll"</string>
<string name="action_disable">"Disable"</string>
<string name="action_done">"Done"</string>
<string name="action_edit">"Edit"</string>
<string name="action_edit_poll">"Edit poll"</string>
<string name="action_enable">"Enable"</string>
<string name="action_end_poll">"End poll"</string>
<string name="action_enter_pin">"Enter PIN"</string>
@@ -93,7 +95,6 @@
<string name="action_try_again">"Try again"</string>
<string name="action_view_source">"View source"</string>
<string name="action_yes">"Yes"</string>
<string name="action_edit_poll">"Edit poll"</string>
<string name="common_about">"About"</string>
<string name="common_acceptable_use_policy">"Acceptable use policy"</string>
<string name="common_advanced_settings">"Advanced settings"</string>

View File

@@ -157,7 +157,8 @@
{
"name": ":features:poll:impl",
"includeRegex": [
"screen_create_poll_.*"
"screen_create_poll_.*",
"screen_edit_poll_.*"
]
},
{