"Create poll" UI (#1143)

NB: This is missing analytics, which will be added once https://github.com/matrix-org/matrix-analytics-events/pull/85 is merged.

Closes https://github.com/vector-im/element-meta/issues/2011
This commit is contained in:
Marco Romano 2023-08-29 22:31:21 +02:00 committed by GitHub
parent 5a85459ec2
commit 633d5282d6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 1032 additions and 112 deletions

View file

@ -46,6 +46,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.poll.api.create.CreatePollEntryPoint
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode
@ -64,6 +65,7 @@ class MessagesFlowNode @AssistedInject constructor(
@Assisted plugins: List<Plugin>,
private val sendLocationEntryPoint: SendLocationEntryPoint,
private val showLocationEntryPoint: ShowLocationEntryPoint,
private val createPollEntryPoint: CreatePollEntryPoint,
) : BackstackNode<MessagesFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Messages,
@ -101,6 +103,9 @@ class MessagesFlowNode @AssistedInject constructor(
@Parcelize
data object SendLocation : NavTarget
@Parcelize
data object CreatePoll : NavTarget
}
private val callback = plugins<MessagesEntryPoint.Callback>().firstOrNull()
@ -140,6 +145,10 @@ class MessagesFlowNode @AssistedInject constructor(
override fun onSendLocationClicked() {
backstack.push(NavTarget.SendLocation)
}
override fun onCreatePollClicked() {
backstack.push(NavTarget.CreatePoll)
}
}
createNode<MessagesNode>(buildContext, listOf(callback))
}
@ -179,6 +188,9 @@ class MessagesFlowNode @AssistedInject constructor(
NavTarget.SendLocation -> {
sendLocationEntryPoint.createNode(this, buildContext)
}
NavTarget.CreatePoll -> {
createPollEntryPoint.createNode(this, buildContext)
}
}
}

View file

@ -58,6 +58,7 @@ class MessagesNode @AssistedInject constructor(
fun onForwardEventClicked(eventId: EventId)
fun onReportMessage(eventId: EventId, senderId: UserId)
fun onSendLocationClicked()
fun onCreatePollClicked()
}
init {
@ -99,6 +100,10 @@ class MessagesNode @AssistedInject constructor(
callback?.onSendLocationClicked()
}
private fun onCreatePollClicked() {
callback?.onCreatePollClicked()
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
@ -110,6 +115,7 @@ class MessagesNode @AssistedInject constructor(
onPreviewAttachments = this::onPreviewAttachments,
onUserDataClicked = this::onUserDataClicked,
onSendLocationClicked = this::onSendLocationClicked,
onCreatePollClicked = this::onCreatePollClicked,
modifier = modifier,
)
}

View file

@ -97,6 +97,7 @@ fun MessagesView(
onUserDataClicked: (UserId) -> Unit,
onPreviewAttachments: (ImmutableList<Attachment>) -> Unit,
onSendLocationClicked: () -> Unit,
onCreatePollClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
LogCompositions(tag = "MessagesScreen", msg = "Root")
@ -175,6 +176,7 @@ fun MessagesView(
onReactionLongClicked = ::onEmojiReactionLongClicked,
onMoreReactionsClicked = ::onMoreReactionsClicked,
onSendLocationClicked = onSendLocationClicked,
onCreatePollClicked = onCreatePollClicked,
onSwipeToReply = { targetEvent ->
state.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Reply, targetEvent))
},
@ -267,6 +269,7 @@ private fun MessagesViewContent(
onMessageLongClicked: (TimelineItem.Event) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit,
onSendLocationClicked: () -> Unit,
onCreatePollClicked: () -> Unit,
modifier: Modifier = Modifier,
onSwipeToReply: (TimelineItem.Event) -> Unit,
) {
@ -295,6 +298,7 @@ private fun MessagesViewContent(
MessageComposerView(
state = state.composerState,
onSendLocationClicked = onSendLocationClicked,
onCreatePollClicked = onCreatePollClicked,
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight(Alignment.Bottom)
@ -401,5 +405,6 @@ private fun ContentToPreview(state: MessagesState) {
onPreviewAttachments = {},
onUserDataClicked = {},
onSendLocationClicked = {},
onCreatePollClicked = {},
)
}

View file

@ -25,6 +25,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.canBeCopied
@ -96,6 +97,22 @@ class ActionListPresenter @Inject constructor(
}
}
}
is TimelineItemPollContent -> {
buildList {
if (timelineItem.content.canBeCopied()) {
add(TimelineItemAction.Copy)
}
if (buildMeta.isDebuggable) {
add(TimelineItemAction.Developer)
}
if (!timelineItem.isMine) {
add(TimelineItemAction.ReportContent)
}
if (timelineItem.isMine || userCanRedact) {
add(TimelineItemAction.Redact)
}
}
}
else -> buildList<TimelineItemAction> {
if (timelineItem.isRemote) {
// Can only reply or forward messages already uploaded to the server

View file

@ -24,6 +24,7 @@ import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ListItem
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.BarChart
import androidx.compose.material.icons.filled.Collections
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.PhotoCamera
@ -52,6 +53,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
internal fun AttachmentsBottomSheet(
state: MessageComposerState,
onSendLocationClicked: () -> Unit,
onCreatePollClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
val localView = LocalView.current
@ -85,6 +87,7 @@ internal fun AttachmentsBottomSheet(
AttachmentSourcePickerMenu(
state = state,
onSendLocationClicked = onSendLocationClicked,
onCreatePollClicked = onCreatePollClicked,
)
}
}
@ -95,6 +98,7 @@ internal fun AttachmentsBottomSheet(
internal fun AttachmentSourcePickerMenu(
state: MessageComposerState,
onSendLocationClicked: () -> Unit,
onCreatePollClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
@ -131,6 +135,16 @@ internal fun AttachmentSourcePickerMenu(
text = { Text(stringResource(R.string.screen_room_attachment_source_location)) },
)
}
if (state.canCreatePoll) {
ListItem(
modifier = Modifier.clickable {
state.eventSink(MessageComposerEvents.PickAttachmentSource.Poll)
onCreatePollClicked()
},
icon = { Icon(Icons.Default.BarChart, null) },
text = { Text(stringResource(R.string.screen_room_attachment_source_poll)) },
)
}
}
}
@ -142,5 +156,6 @@ internal fun AttachmentSourcePickerMenuPreview() = ElementPreview {
canShareLocation = true,
),
onSendLocationClicked = {},
onCreatePollClicked = {},
)
}

View file

@ -35,6 +35,7 @@ sealed interface MessageComposerEvents {
data object PhotoFromCamera : PickAttachmentSource
data object VideoFromCamera : PickAttachmentSource
data object Location : PickAttachmentSource
data object Poll : PickAttachmentSource
}
data object CancelSendAttachment : MessageComposerEvents
}

View file

@ -83,6 +83,11 @@ class MessageComposerPresenter @Inject constructor(
canShareLocation.value = featureFlagService.isFeatureEnabled(FeatureFlags.LocationSharing)
}
val canCreatePoll = remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
canCreatePoll.value = featureFlagService.isFeatureEnabled(FeatureFlags.Polls)
}
val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker { uri, mimeType ->
handlePickedMedia(attachmentsState, uri, mimeType)
}
@ -179,6 +184,10 @@ class MessageComposerPresenter @Inject constructor(
showAttachmentSourcePicker = false
// Navigation to the location picker screen is done at the view layer
}
MessageComposerEvents.PickAttachmentSource.Poll -> {
showAttachmentSourcePicker = false
// Navigation to the create poll screen is done at the view layer
}
is MessageComposerEvents.CancelSendAttachment -> {
ongoingSendAttachmentJob.value?.let {
it.cancel()
@ -195,6 +204,7 @@ class MessageComposerPresenter @Inject constructor(
mode = messageComposerContext.composerMode,
showAttachmentSourcePicker = showAttachmentSourcePicker,
canShareLocation = canShareLocation.value,
canCreatePoll = canCreatePoll.value,
attachmentsState = attachmentsState.value,
eventSink = ::handleEvents
)

View file

@ -29,6 +29,7 @@ data class MessageComposerState(
val mode: MessageComposerMode,
val showAttachmentSourcePicker: Boolean,
val canShareLocation: Boolean,
val canCreatePoll: Boolean,
val attachmentsState: AttachmentsState,
val eventSink: (MessageComposerEvents) -> Unit
) {

View file

@ -33,6 +33,7 @@ fun aMessageComposerState(
mode: MessageComposerMode = MessageComposerMode.Normal(content = ""),
showAttachmentSourcePicker: Boolean = false,
canShareLocation: Boolean = true,
canCreatePoll: Boolean = true,
attachmentsState: AttachmentsState = AttachmentsState.None,
) = MessageComposerState(
text = text,
@ -41,6 +42,7 @@ fun aMessageComposerState(
mode = mode,
showAttachmentSourcePicker = showAttachmentSourcePicker,
canShareLocation = canShareLocation,
canCreatePoll = canCreatePoll,
attachmentsState = attachmentsState,
eventSink = {},
)

View file

@ -29,6 +29,7 @@ import io.element.android.libraries.textcomposer.TextComposer
fun MessageComposerView(
state: MessageComposerState,
onSendLocationClicked: () -> Unit,
onCreatePollClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
fun onFullscreenToggle() {
@ -59,6 +60,7 @@ fun MessageComposerView(
AttachmentsBottomSheet(
state = state,
onSendLocationClicked = onSendLocationClicked,
onCreatePollClicked = onCreatePollClicked,
)
TextComposer(
@ -88,6 +90,7 @@ internal fun MessageComposerViewDarkPreview(@PreviewParameter(MessageComposerSta
private fun ContentToPreview(state: MessageComposerState) {
MessageComposerView(
state = state,
onSendLocationClicked = {}
onSendLocationClicked = {},
onCreatePollClicked = {},
)
}

View file

@ -26,10 +26,14 @@ import io.element.android.features.messages.impl.actionlist.ActionListPresenter
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
import io.element.android.features.poll.api.PollAnswerItem
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.A_MESSAGE
import io.element.android.libraries.matrix.test.core.aBuildMeta
import kotlinx.collections.immutable.persistentListOf
@ -369,6 +373,57 @@ class ActionListPresenterTest {
assertThat(successState.displayEmojiReactions).isFalse()
}
}
@Test
fun `present - compute for poll message`() = runTest {
val presenter = anActionListPresenter(isBuildDebuggable = false)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
content = TimelineItemPollContent(
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,
),
),
votes = mapOf(),
pollKind = PollKind.Disclosed,
isEnded = false,
)
)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, false))
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
messageEvent,
persistentListOf(
TimelineItemAction.Redact,
)
)
)
assertThat(successState.displayEmojiReactions).isTrue()
}
}
}
private fun anActionListPresenter(isBuildDebuggable: Boolean) = ActionListPresenter(buildMeta = aBuildMeta(isDebuggable = isBuildDebuggable))