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 4e52244b86
commit 8fcec4a006
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
50 changed files with 827 additions and 173 deletions

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(),