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:
1
changelog.d/1869.feature
Normal file
1
changelog.d/1869.feature
Normal file
@@ -0,0 +1 @@
|
||||
Allow polls to be edited when they have not been voted on
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -30,4 +30,8 @@ sealed interface TimelineEvents {
|
||||
data class PollEndClicked(
|
||||
val pollStartId: EventId,
|
||||
) : TimelineEvents
|
||||
|
||||
data class PollEditClicked(
|
||||
val pollStartId: EventId,
|
||||
) : TimelineEvents
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -526,6 +526,7 @@ private fun MessageEventBubbleContent(
|
||||
TimelineItemEventContentView(
|
||||
content = event.content,
|
||||
isMine = event.isMine,
|
||||
isEditable = event.isEditable,
|
||||
interactionSource = interactionSource,
|
||||
onClick = onMessageClick,
|
||||
onLongClick = onMessageLongClick,
|
||||
|
||||
@@ -68,6 +68,7 @@ fun TimelineItemStateEventRow(
|
||||
TimelineItemEventContentView(
|
||||
content = event.content,
|
||||
isMine = event.isMine,
|
||||
isEditable = event.isEditable,
|
||||
interactionSource = interactionSource,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = {},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -157,7 +157,8 @@
|
||||
{
|
||||
"name": ":features:poll:impl",
|
||||
"includeRegex": [
|
||||
"screen_create_poll_.*"
|
||||
"screen_create_poll_.*",
|
||||
"screen_edit_poll_.*"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user