diff --git a/build.gradle.kts b/build.gradle.kts index 556ee5ff00..b48ef70ce7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,7 +5,7 @@ import org.jetbrains.kotlin.cli.common.toBooleanLenient buildscript { dependencies { - classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.0") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.10") classpath("com.google.gms:google-services:4.3.15") } } diff --git a/changelog.d/1143.feature b/changelog.d/1143.feature new file mode 100644 index 0000000000..84a86f4f25 --- /dev/null +++ b/changelog.d/1143.feature @@ -0,0 +1 @@ +Create poll. diff --git a/changelog.d/1168.bugfix b/changelog.d/1168.bugfix new file mode 100644 index 0000000000..f7f959ac0a --- /dev/null +++ b/changelog.d/1168.bugfix @@ -0,0 +1 @@ +Bug reporter crashes when 'send logs' is disabled. diff --git a/changelog.d/1177.bugfix b/changelog.d/1177.bugfix new file mode 100644 index 0000000000..edbf2e9006 --- /dev/null +++ b/changelog.d/1177.bugfix @@ -0,0 +1 @@ +Add missing link to the terms on the analytics setting screen. diff --git a/changelog.d/928.bugfix b/changelog.d/928.bugfix new file mode 100644 index 0000000000..98a4cd34e0 --- /dev/null +++ b/changelog.d/928.bugfix @@ -0,0 +1 @@ +Make sure Snackbars are only displayed once. diff --git a/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesState.kt b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesState.kt index 7cf0f51dfd..e03796297e 100644 --- a/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesState.kt +++ b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesState.kt @@ -21,5 +21,6 @@ import io.element.android.features.analytics.api.AnalyticsOptInEvents data class AnalyticsPreferencesState( val applicationName: String, val isEnabled: Boolean, + val policyUrl: String, val eventSink: (AnalyticsOptInEvents) -> Unit, ) diff --git a/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesStateProvider.kt b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesStateProvider.kt index ea397b4d67..18f902a6fd 100644 --- a/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesStateProvider.kt +++ b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesStateProvider.kt @@ -28,5 +28,6 @@ open class AnalyticsPreferencesStateProvider : PreviewParameterProvider onClickTerms() } + }, modifier = Modifier - .clip(shape = RoundedCornerShape(8.dp)) - .clickable { onClickTerms() } .padding(8.dp), - style = ElementTheme.typography.fontBodyMdRegular, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.secondary, + style = ElementTheme.typography.fontBodyMdRegular + .copy( + color = MaterialTheme.colorScheme.secondary, + textAlign = TextAlign.Center, + ) ) } } diff --git a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/preferences/DefaultAnalyticsPreferencesPresenter.kt b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/preferences/DefaultAnalyticsPreferencesPresenter.kt index 6debe4c232..06431402a6 100644 --- a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/preferences/DefaultAnalyticsPreferencesPresenter.kt +++ b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/preferences/DefaultAnalyticsPreferencesPresenter.kt @@ -21,6 +21,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.rememberCoroutineScope import com.squareup.anvil.annotations.ContributesBinding import io.element.android.features.analytics.api.AnalyticsOptInEvents +import io.element.android.features.analytics.api.Config import io.element.android.features.analytics.api.preferences.AnalyticsPreferencesPresenter import io.element.android.features.analytics.api.preferences.AnalyticsPreferencesState import io.element.android.libraries.core.meta.BuildMeta @@ -51,6 +52,7 @@ class DefaultAnalyticsPreferencesPresenter @Inject constructor( return AnalyticsPreferencesState( applicationName = buildMeta.applicationName, isEnabled = isEnabled.value, + policyUrl = Config.POLICY_LINK, eventSink = ::handleEvents ) } diff --git a/features/analytics/impl/src/test/kotlin/io/element/android/features/analytics/impl/preferences/AnalyticsPreferencesPresenterTest.kt b/features/analytics/impl/src/test/kotlin/io/element/android/features/analytics/impl/preferences/AnalyticsPreferencesPresenterTest.kt index 843e5b5532..29c4579d8f 100644 --- a/features/analytics/impl/src/test/kotlin/io/element/android/features/analytics/impl/preferences/AnalyticsPreferencesPresenterTest.kt +++ b/features/analytics/impl/src/test/kotlin/io/element/android/features/analytics/impl/preferences/AnalyticsPreferencesPresenterTest.kt @@ -39,6 +39,7 @@ class AnalyticsPreferencesPresenterTest { skipItems(1) val initialState = awaitItem() assertThat(initialState.isEnabled).isTrue() + assertThat(initialState.policyUrl).isNotEmpty() } } diff --git a/features/login/impl/build.gradle.kts b/features/login/impl/build.gradle.kts index 91b8a2f543..ae13197f05 100644 --- a/features/login/impl/build.gradle.kts +++ b/features/login/impl/build.gradle.kts @@ -19,7 +19,7 @@ plugins { alias(libs.plugins.anvil) alias(libs.plugins.ksp) id("kotlin-parcelize") - kotlin("plugin.serialization") version "1.9.0" + kotlin("plugin.serialization") version "1.9.10" } android { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index eee68768be..fcb2e7e5e8 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -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, private val sendLocationEntryPoint: SendLocationEntryPoint, private val showLocationEntryPoint: ShowLocationEntryPoint, + private val createPollEntryPoint: CreatePollEntryPoint, ) : BackstackNode( 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().firstOrNull() @@ -140,6 +145,10 @@ class MessagesFlowNode @AssistedInject constructor( override fun onSendLocationClicked() { backstack.push(NavTarget.SendLocation) } + + override fun onCreatePollClicked() { + backstack.push(NavTarget.CreatePoll) + } } createNode(buildContext, listOf(callback)) } @@ -179,6 +188,9 @@ class MessagesFlowNode @AssistedInject constructor( NavTarget.SendLocation -> { sendLocationEntryPoint.createNode(this, buildContext) } + NavTarget.CreatePoll -> { + createPollEntryPoint.createNode(this, buildContext) + } } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt index 3f201a8e4c..6a3cf502d7 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt @@ -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, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index 74dcb610af..9810845228 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -97,6 +97,7 @@ fun MessagesView( onUserDataClicked: (UserId) -> Unit, onPreviewAttachments: (ImmutableList) -> 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)) }, @@ -266,6 +268,7 @@ private fun MessagesViewContent( onMessageLongClicked: (TimelineItem.Event) -> Unit, onTimestampClicked: (TimelineItem.Event) -> Unit, onSendLocationClicked: () -> Unit, + onCreatePollClicked: () -> Unit, modifier: Modifier = Modifier, onSwipeToReply: (TimelineItem.Event) -> Unit, ) { @@ -294,6 +297,7 @@ private fun MessagesViewContent( MessageComposerView( state = state.composerState, onSendLocationClicked = onSendLocationClicked, + onCreatePollClicked = onCreatePollClicked, modifier = Modifier .fillMaxWidth() .wrapContentHeight(Alignment.Bottom) @@ -400,5 +404,6 @@ private fun ContentToPreview(state: MessagesState) { onPreviewAttachments = {}, onUserDataClicked = {}, onSendLocationClicked = {}, + onCreatePollClicked = {}, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt index f71c750c22..e654365bcd 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt @@ -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 { if (timelineItem.isRemote) { // Can only reply or forward messages already uploaded to the server diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/AttachmentsBottomSheet.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/AttachmentsBottomSheet.kt index b554ef98f4..43805bb5c0 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/AttachmentsBottomSheet.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/AttachmentsBottomSheet.kt @@ -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 = {}, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt index a39fe45ea8..d99eb3c158 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt @@ -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 } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index 5477b10c63..cc735dc008 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -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 ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt index 1b5bf3fe82..dbbc62ca47 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt @@ -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 ) { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt index a1fbb7ffa0..2217b574b4 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt @@ -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 = {}, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt index 844635cef7..fd5421988b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt @@ -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 = {}, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt index 7b04d60ade..c6b0218bba 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt @@ -21,7 +21,9 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height +import androidx.compose.material3.LocalContentColor import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview @@ -35,6 +37,7 @@ import io.element.android.libraries.designsystem.components.ClickableLinkText import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.text.toAnnotatedString +import io.element.android.libraries.theme.ElementTheme @Composable fun TimelineItemTextView( @@ -45,31 +48,33 @@ fun TimelineItemTextView( onTextClicked: () -> Unit = {}, onTextLongClicked: () -> Unit = {}, ) { - val htmlDocument = content.htmlDocument - if (htmlDocument != null) { - // For now we ignore the extra padding for html content, so add some spacing - // below the content (as previous behavior) - Column(modifier = modifier) { - HtmlDocument( - document = htmlDocument, - modifier = Modifier, - onTextClicked = onTextClicked, - onTextLongClicked = onTextLongClicked, - interactionSource = interactionSource - ) - Spacer(Modifier.height(16.dp)) - } - } else { - Box(modifier) { - val textWithPadding = remember(content.body) { - content.body + extraPadding.getStr(16.sp).toAnnotatedString() + CompositionLocalProvider(LocalContentColor provides ElementTheme.colors.textPrimary) { + val htmlDocument = content.htmlDocument + if (htmlDocument != null) { + // For now we ignore the extra padding for html content, so add some spacing + // below the content (as previous behavior) + Column(modifier = modifier) { + HtmlDocument( + document = htmlDocument, + modifier = Modifier, + onTextClicked = onTextClicked, + onTextLongClicked = onTextLongClicked, + interactionSource = interactionSource + ) + Spacer(Modifier.height(16.dp)) + } + } else { + Box(modifier) { + val textWithPadding = remember(content.body) { + content.body + extraPadding.getStr(16.sp).toAnnotatedString() + } + ClickableLinkText( + text = textWithPadding, + onClick = onTextClicked, + onLongClick = onTextLongClicked, + interactionSource = interactionSource + ) } - ClickableLinkText( - text = textWithPadding, - onClick = onTextClicked, - onLongClick = onTextLongClicked, - interactionSource = interactionSource - ) } } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentPollFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentPollFactory.kt index 4a21874e1c..9c06b17056 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentPollFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentPollFactory.kt @@ -69,7 +69,6 @@ class TimelineItemContentPollFactory @Inject constructor( return TimelineItemPollContent( question = content.question, answerItems = answerItems, - votes = content.votes, pollKind = content.kind, isEnded = isEndedPoll, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContent.kt index 3c0e0edfd4..0f94b97776 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContent.kt @@ -17,13 +17,11 @@ package io.element.android.features.messages.impl.timeline.model.event import io.element.android.features.poll.api.PollAnswerItem -import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.poll.PollKind data class TimelineItemPollContent( val question: String, val answerItems: List, - val votes: Map>, val pollKind: PollKind, val isEnded: Boolean, ) : TimelineItemEventContent { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContentProvider.kt index 49dfa58c8a..247d450ae7 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContentProvider.kt @@ -34,6 +34,5 @@ fun aTimelineItemPollContent(): TimelineItemPollContent { question = "What type of food should we have at the party?", answerItems = aPollAnswerItemList(), isEnded = false, - votes = emptyMap(), ) } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt index afef9f6730..b3c805d32d 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt @@ -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,56 @@ 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, + ), + ), + 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)) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/factories/event/TimelineItemContentPollFactoryTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/factories/event/TimelineItemContentPollFactoryTest.kt new file mode 100644 index 0000000000..a1411293e9 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/factories/event/TimelineItemContentPollFactoryTest.kt @@ -0,0 +1,279 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.messages.timeline.factories.event + +import com.google.common.truth.Truth +import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentPollFactory +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent +import io.element.android.features.poll.api.PollAnswerItem +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.matrix.api.core.UserId +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.timeline.item.event.PollContent +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.A_USER_ID_10 +import io.element.android.libraries.matrix.test.A_USER_ID_2 +import io.element.android.libraries.matrix.test.A_USER_ID_3 +import io.element.android.libraries.matrix.test.A_USER_ID_4 +import io.element.android.libraries.matrix.test.A_USER_ID_5 +import io.element.android.libraries.matrix.test.A_USER_ID_6 +import io.element.android.libraries.matrix.test.A_USER_ID_7 +import io.element.android.libraries.matrix.test.A_USER_ID_8 +import io.element.android.libraries.matrix.test.A_USER_ID_9 +import io.element.android.libraries.matrix.test.FakeMatrixClient +import kotlinx.coroutines.test.runTest +import org.junit.Test + +internal class TimelineItemContentPollFactoryTest { + + private val factory = TimelineItemContentPollFactory( + matrixClient = FakeMatrixClient(), + featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.Polls.key to true)), + ) + + @Test + fun `Disclosed poll - not ended, no votes`() = runTest { + Truth.assertThat(factory.create(aPollContent())).isEqualTo(aTimelineItemPollContent()) + } + + @Test + fun `Disclosed poll - not ended, some votes, including one from current user`() = runTest { + val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id } + Truth.assertThat( + factory.create(aPollContent(votes = votes)) + ) + .isEqualTo( + aTimelineItemPollContent( + answerItems = listOf( + aPollAnswerItem(answer = A_POLL_ANSWER_1, votesCount = 3, percentage = 0.3f), + aPollAnswerItem(answer = A_POLL_ANSWER_2, isSelected = true, votesCount = 6, percentage = 0.6f), + aPollAnswerItem(answer = A_POLL_ANSWER_3), + aPollAnswerItem(answer = A_POLL_ANSWER_4, votesCount = 1, percentage = 0.1f), + ), + ) + ) + } + + @Test + fun `Disclosed poll - ended, no votes, no winner`() = runTest { + Truth.assertThat( + factory.create(aPollContent(endTime = 1UL)) + ).isEqualTo( + aTimelineItemPollContent().let { + it.copy( + answerItems = it.answerItems.map { answerItem -> answerItem.copy(isEnabled = false) }, + isEnded = true, + ) + } + ) + } + + @Test + fun `Disclosed poll - ended, some votes, including one from current user (winner)`() = runTest { + val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id } + Truth.assertThat( + factory.create(aPollContent(votes = votes, endTime = 1UL)) + ) + .isEqualTo( + aTimelineItemPollContent( + answerItems = listOf( + aPollAnswerItem(answer = A_POLL_ANSWER_1, isEnabled = false, votesCount = 3, percentage = 0.3f), + aPollAnswerItem(answer = A_POLL_ANSWER_2, isSelected = true, isEnabled = false, isWinner = true, votesCount = 6, percentage = 0.6f), + aPollAnswerItem(answer = A_POLL_ANSWER_3, isEnabled = false), + aPollAnswerItem(answer = A_POLL_ANSWER_4, isEnabled = false, votesCount = 1, percentage = 0.1f), + ), + isEnded = true, + ) + ) + } + + @Test + fun `Disclosed poll - ended, some votes, including one from current user (not winner) and two winning votes`() = runTest { + val votes = OTHER_WINNING_VOTES.mapKeys { it.key.id } + Truth.assertThat( + factory.create(aPollContent(votes = votes, endTime = 1UL)) + ) + .isEqualTo( + aTimelineItemPollContent( + answerItems = listOf( + aPollAnswerItem(answer = A_POLL_ANSWER_1, isEnabled = false, isWinner = true, votesCount = 4, percentage = 0.4f), + aPollAnswerItem(answer = A_POLL_ANSWER_2, isSelected = true, isEnabled = false, votesCount = 2, percentage = 0.2f), + aPollAnswerItem(answer = A_POLL_ANSWER_3, isEnabled = false), + aPollAnswerItem(answer = A_POLL_ANSWER_4, isEnabled = false, isWinner = true, votesCount = 4, percentage = 0.4f), + ), + isEnded = true, + ) + ) + } + + @Test + fun `Undisclosed poll - not ended, no votes`() = runTest { + Truth.assertThat( + factory.create(aPollContent(PollKind.Undisclosed).copy()) + ).isEqualTo( + aTimelineItemPollContent(PollKind.Undisclosed).let { + it.copy(answerItems = it.answerItems.map { answerItem -> answerItem.copy(isDisclosed = false) }) + } + ) + } + + @Test + fun `Undisclosed poll - not ended, some votes, including one from current user`() = runTest { + val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id } + Truth.assertThat( + factory.create(aPollContent(pollKind = PollKind.Undisclosed, votes = votes)) + ) + .isEqualTo( + aTimelineItemPollContent( + pollKind = PollKind.Undisclosed, + answerItems = listOf( + aPollAnswerItem(answer = A_POLL_ANSWER_1, isDisclosed = false, votesCount = 3, percentage = 0.3f), + aPollAnswerItem(answer = A_POLL_ANSWER_2, isDisclosed = false, isSelected = true, votesCount = 6, percentage = 0.6f), + aPollAnswerItem(answer = A_POLL_ANSWER_3, isDisclosed = false), + aPollAnswerItem(answer = A_POLL_ANSWER_4, isDisclosed = false, votesCount = 1, percentage = 0.1f), + ), + ) + ) + } + + @Test + fun `Undisclosed poll - ended, no votes, no winner`() = runTest { + Truth.assertThat( + factory.create(aPollContent(pollKind = PollKind.Undisclosed, endTime = 1UL)) + ).isEqualTo( + aTimelineItemPollContent().let { + it.copy( + pollKind = PollKind.Undisclosed, + answerItems = it.answerItems.map { answerItem -> + answerItem.copy(isDisclosed = true, isEnabled = false, isWinner = false) + }, + isEnded = true, + ) + } + ) + } + + @Test + fun `Undisclosed poll - ended, some votes, including one from current user (winner)`() = runTest { + val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id } + Truth.assertThat( + factory.create(aPollContent(pollKind = PollKind.Undisclosed, votes = votes, endTime = 1UL)) + ) + .isEqualTo( + aTimelineItemPollContent( + pollKind = PollKind.Undisclosed, + answerItems = listOf( + aPollAnswerItem(answer = A_POLL_ANSWER_1, isEnabled = false, votesCount = 3, percentage = 0.3f), + aPollAnswerItem(answer = A_POLL_ANSWER_2, isSelected = true, isEnabled = false, isWinner = true, votesCount = 6, percentage = 0.6f), + aPollAnswerItem(answer = A_POLL_ANSWER_3, isEnabled = false), + aPollAnswerItem(answer = A_POLL_ANSWER_4, isEnabled = false, votesCount = 1, percentage = 0.1f), + ), + isEnded = true, + ) + ) + } + + @Test + fun `Undisclosed poll - ended, some votes, including one from current user (not winner) and two winning votes`() = runTest { + val votes = OTHER_WINNING_VOTES.mapKeys { it.key.id } + Truth.assertThat( + factory.create(aPollContent(PollKind.Undisclosed).copy(votes = votes, endTime = 1UL)) + ) + .isEqualTo( + aTimelineItemPollContent( + pollKind = PollKind.Undisclosed, + answerItems = listOf( + aPollAnswerItem(A_POLL_ANSWER_1, isEnabled = false, isWinner = true, votesCount = 4, percentage = 0.4f), + aPollAnswerItem(A_POLL_ANSWER_2, isSelected = true, isEnabled = false, votesCount = 2, percentage = 0.2f), + aPollAnswerItem(A_POLL_ANSWER_3, isEnabled = false), + aPollAnswerItem(A_POLL_ANSWER_4, isEnabled = false, isWinner = true, votesCount = 4, percentage = 0.4f), + ), + isEnded = true, + ) + ) + } + + private fun aPollContent( + pollKind: PollKind = PollKind.Disclosed, + votes: Map> = emptyMap(), + endTime: ULong? = null, + ): PollContent = PollContent( + question = A_POLL_QUESTION, + kind = pollKind, + maxSelections = 1UL, + answers = listOf(A_POLL_ANSWER_1, A_POLL_ANSWER_2, A_POLL_ANSWER_3, A_POLL_ANSWER_4), + votes = votes, + endTime = endTime, + ) + + private fun aTimelineItemPollContent( + pollKind: PollKind = PollKind.Disclosed, + answerItems: List = listOf( + aPollAnswerItem(A_POLL_ANSWER_1), + aPollAnswerItem(A_POLL_ANSWER_2), + aPollAnswerItem(A_POLL_ANSWER_3), + aPollAnswerItem(A_POLL_ANSWER_4), + ), + isEnded: Boolean = false, + ) = TimelineItemPollContent( + question = A_POLL_QUESTION, + answerItems = answerItems, + pollKind = pollKind, + isEnded = isEnded, + ) + + private fun aPollAnswerItem( + answer: PollAnswer, + isSelected: Boolean = false, + isEnabled: Boolean = true, + isWinner: Boolean = false, + isDisclosed: Boolean = true, + votesCount: Int = 0, + percentage: Float = 0f, + ) = PollAnswerItem( + answer = answer, + isSelected = isSelected, + isEnabled = isEnabled, + isWinner = isWinner, + isDisclosed = isDisclosed, + votesCount = votesCount, + percentage = percentage, + ) + + private companion object TestData { + private const val A_POLL_QUESTION = "What is your favorite food?" + private val A_POLL_ANSWER_1 = PollAnswer("id_1", "Pizza") + private val A_POLL_ANSWER_2 = PollAnswer("id_2", "Pasta") + private val A_POLL_ANSWER_3 = PollAnswer("id_3", "French Fries") + private val A_POLL_ANSWER_4 = PollAnswer("id_4", "Hamburger") + + private val MY_USER_WINNING_VOTES = mapOf( + A_POLL_ANSWER_1 to listOf(A_USER_ID_2, A_USER_ID_3, A_USER_ID_4), + A_POLL_ANSWER_2 to listOf(A_USER_ID /* my vote */, A_USER_ID_5, A_USER_ID_6, A_USER_ID_7, A_USER_ID_8, A_USER_ID_9), // winner + A_POLL_ANSWER_3 to emptyList(), + A_POLL_ANSWER_4 to listOf(A_USER_ID_10), + ) + private val OTHER_WINNING_VOTES = mapOf( + A_POLL_ANSWER_1 to listOf(A_USER_ID_2, A_USER_ID_3, A_USER_ID_4, A_USER_ID_5), // winner + A_POLL_ANSWER_2 to listOf(A_USER_ID /* my vote */, A_USER_ID_6), + A_POLL_ANSWER_3 to emptyList(), + A_POLL_ANSWER_4 to listOf(A_USER_ID_7, A_USER_ID_8, A_USER_ID_9, A_USER_ID_10), // winner + ) + } +} diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/PollEntryPoint.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/create/CreatePollEntryPoint.kt similarity index 65% rename from features/poll/api/src/main/kotlin/io/element/android/features/poll/api/PollEntryPoint.kt rename to features/poll/api/src/main/kotlin/io/element/android/features/poll/api/create/CreatePollEntryPoint.kt index d8f2aed846..abbb041374 100644 --- a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/PollEntryPoint.kt +++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/create/CreatePollEntryPoint.kt @@ -14,24 +14,12 @@ * limitations under the License. */ -package io.element.android.features.poll.api +package io.element.android.features.poll.api.create import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node -import com.bumble.appyx.core.plugin.Plugin import io.element.android.libraries.architecture.FeatureEntryPoint -interface PollEntryPoint : FeatureEntryPoint { - - fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder - - interface NodeBuilder { - fun callback(callback: Callback): NodeBuilder - fun build(): Node - } - - interface Callback : Plugin { - // Add your callbacks - } +interface CreatePollEntryPoint : FeatureEntryPoint { + fun createNode(parentNode: Node, buildContext: BuildContext): Node } - diff --git a/features/poll/impl/build.gradle.kts b/features/poll/impl/build.gradle.kts index 626a7d0f2c..4e8d36966a 100644 --- a/features/poll/impl/build.gradle.kts +++ b/features/poll/impl/build.gradle.kts @@ -14,8 +14,6 @@ * limitations under the License. */ -// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed -@Suppress("DSL_SCOPE_VIOLATION") plugins { id("io.element.android-compose-library") alias(libs.plugins.anvil) @@ -40,6 +38,8 @@ dependencies { implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrixui) implementation(projects.libraries.designsystem) + implementation(projects.services.analytics.api) + implementation(projects.libraries.uiStrings) testImplementation(libs.test.junit) testImplementation(libs.coroutines.test) @@ -47,6 +47,7 @@ dependencies { testImplementation(libs.test.truth) testImplementation(libs.test.turbine) testImplementation(projects.libraries.matrix.test) + testImplementation(projects.services.analytics.test) ksp(libs.showkase.processor) } diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/PollFlowNode.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/PollFlowNode.kt deleted file mode 100644 index f983025236..0000000000 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/PollFlowNode.kt +++ /dev/null @@ -1,70 +0,0 @@ -/* - * 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 - -import android.os.Parcelable -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import com.bumble.appyx.core.composable.Children -import com.bumble.appyx.core.modality.BuildContext -import com.bumble.appyx.core.node.Node -import com.bumble.appyx.core.plugin.Plugin -import com.bumble.appyx.navmodel.backstack.BackStack -import dagger.assisted.Assisted -import dagger.assisted.AssistedInject -import io.element.android.anvilannotations.ContributesNode -import io.element.android.libraries.architecture.BackstackNode -import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler -import io.element.android.libraries.architecture.createNode -import io.element.android.libraries.di.SessionScope -import kotlinx.parcelize.Parcelize - -@ContributesNode(SessionScope::class) -class PollFlowNode @AssistedInject constructor( - @Assisted buildContext: BuildContext, - @Assisted plugins: List, -) : BackstackNode( - backstack = BackStack( - initialElement = NavTarget.Root, - savedStateMap = buildContext.savedStateMap, - ), - buildContext = buildContext, - plugins = plugins, -) { - - sealed interface NavTarget : Parcelable { - @Parcelize - data object Root : NavTarget - } - - override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { - return when (navTarget) { - NavTarget.Root -> { - createNode(buildContext) - } - } - } - - @Composable - override fun View(modifier: Modifier) { - Children( - navModel = backstack, - modifier = modifier, - transitionHandler = rememberDefaultTransitionHandler(), - ) - } -} diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollEvents.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollEvents.kt new file mode 100644 index 0000000000..1251e07696 --- /dev/null +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollEvents.kt @@ -0,0 +1,31 @@ +/* + * 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 + +import io.element.android.libraries.matrix.api.poll.PollKind + +sealed interface CreatePollEvents { + data object Create : CreatePollEvents + data class SetQuestion(val question: String) : CreatePollEvents + data class SetAnswer(val index: Int, val text: String) : CreatePollEvents + data object AddAnswer : CreatePollEvents + data class RemoveAnswer(val index: Int) : CreatePollEvents + data class SetPollKind(val pollKind: PollKind) : CreatePollEvents + data object NavBack : CreatePollEvents + data object ConfirmNavBack : CreatePollEvents + data object HideConfirmation : CreatePollEvents +} diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollNode.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollNode.kt new file mode 100644 index 0000000000..387f57a597 --- /dev/null +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollNode.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.poll.impl.create + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.lifecycle.subscribe +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.libraries.di.RoomScope + +@ContributesNode(RoomScope::class) +class CreatePollNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: CreatePollPresenter.Factory, + // analyticsService: AnalyticsService, // TODO Polls: add analytics +) : Node(buildContext, plugins = plugins) { + + private val presenter = presenterFactory.create(backNavigator = ::navigateUp) + + init { + lifecycle.subscribe( + onResume = { + // TODO Polls: add analytics + // analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.PollView)) + } + ) + } + + @Composable + override fun View(modifier: Modifier) { + CreatePollView( + state = presenter.present(), + modifier = modifier, + ) + } +} diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenter.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenter.kt new file mode 100644 index 0000000000..b44afae9d1 --- /dev/null +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenter.kt @@ -0,0 +1,162 @@ +/* + * 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 + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.poll.PollKind +import io.element.android.libraries.matrix.api.room.MatrixRoom +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.launch +import timber.log.Timber + +private const val MIN_ANSWERS = 2 +private const val MAX_ANSWERS = 20 +private const val MAX_ANSWER_LENGTH = 240 +private const val MAX_SELECTIONS = 1 + +class CreatePollPresenter @AssistedInject constructor( + private val room: MatrixRoom, + // private val analyticsService: AnalyticsService, // TODO Polls: add analytics + @Assisted private val navigateUp: () -> Unit, + // private val messageComposerContext: MessageComposerContext, // TODO Polls: add analytics +) : Presenter { + + @AssistedFactory + interface Factory { + fun create(backNavigator: () -> Unit): CreatePollPresenter + } + + @Composable + override fun present(): CreatePollState { + + var question: String by rememberSaveable { mutableStateOf("") } + var answers: List 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) } } + val canAddAnswer: Boolean by remember { derivedStateOf { canAddAnswer(answers) } } + val immutableAnswers: ImmutableList by remember { derivedStateOf { answers.toAnswers() } } + + val scope = rememberCoroutineScope() + + fun handleEvents(event: CreatePollEvents) { + when (event) { + is CreatePollEvents.Create -> scope.launch { + if (canCreate) { + room.createPoll( + question = question, + answers = answers, + maxSelections = MAX_SELECTIONS, + pollKind = pollKind, + ) + // analyticsService.capture(PollCreate()) // TODO Polls: add analytics + navigateUp() + } else { + Timber.d("Cannot create poll") + } + } + is CreatePollEvents.AddAnswer -> { + answers = answers + "" + } + is CreatePollEvents.RemoveAnswer -> { + answers = answers.filterIndexed { index, _ -> index != event.index } + } + is CreatePollEvents.SetAnswer -> { + answers = answers.toMutableList().apply { + this[event.index] = event.text.take(MAX_ANSWER_LENGTH) + } + } + is CreatePollEvents.SetPollKind -> { + pollKind = event.pollKind + } + is CreatePollEvents.SetQuestion -> { + question = event.question + } + is CreatePollEvents.NavBack -> { + navigateUp() + } + CreatePollEvents.ConfirmNavBack -> { + val shouldConfirm = question.isNotBlank() || answers.any { it.isNotBlank() } + if (shouldConfirm) { + showConfirmation = true + } else { + navigateUp() + } + } + is CreatePollEvents.HideConfirmation -> showConfirmation = false + } + } + + return CreatePollState( + canCreate = canCreate, + canAddAnswer = canAddAnswer, + question = question, + answers = immutableAnswers, + pollKind = pollKind, + showConfirmation = showConfirmation, + eventSink = ::handleEvents, + ) + } +} + +private fun canCreate( + question: String, + answers: List +) = question.isNotBlank() && answers.size >= MIN_ANSWERS && answers.all { it.isNotBlank() } + +private fun canAddAnswer(answers: List) = answers.size < MAX_ANSWERS + +private fun List.toAnswers(): ImmutableList { + return map { answer -> + Answer( + text = answer, + canDelete = this.size > MIN_ANSWERS, + ) + }.toImmutableList() +} + +private val pollKindSaver: Saver, Boolean> = Saver( + save = { + when (it.value) { + PollKind.Disclosed -> false + PollKind.Undisclosed -> true + } + }, + restore = { + mutableStateOf( + when(it) { + true -> PollKind.Undisclosed + else -> PollKind.Disclosed + } + ) + } +) diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollState.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollState.kt new file mode 100644 index 0000000000..eccaea45fc --- /dev/null +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollState.kt @@ -0,0 +1,35 @@ +/* + * 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 + +import io.element.android.libraries.matrix.api.poll.PollKind +import kotlinx.collections.immutable.ImmutableList + +data class CreatePollState( + val canCreate: Boolean, + val canAddAnswer: Boolean, + val question: String, + val answers: ImmutableList, + val pollKind: PollKind, + val showConfirmation: Boolean, + val eventSink: (CreatePollEvents) -> Unit = {}, +) + +data class Answer( + val text: String, + val canDelete: Boolean, +) diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollStateProvider.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollStateProvider.kt new file mode 100644 index 0000000000..29aa1288ad --- /dev/null +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollStateProvider.kt @@ -0,0 +1,124 @@ +/* + * 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 + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.matrix.api.poll.PollKind +import kotlinx.collections.immutable.persistentListOf + +class CreatePollStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + CreatePollState( + canCreate = false, + canAddAnswer = true, + question = "", + answers = persistentListOf( + Answer("", false), + Answer("", false) + ), + pollKind = PollKind.Disclosed, + showConfirmation = false, + ), + CreatePollState( + canCreate = true, + canAddAnswer = true, + question = "What type of food should we have?", + answers = persistentListOf( + Answer("Italian \uD83C\uDDEE\uD83C\uDDF9", false), + Answer("Chinese \uD83C\uDDE8\uD83C\uDDF3", false), + ), + showConfirmation = false, + pollKind = PollKind.Undisclosed, + ), + CreatePollState( + canCreate = true, + canAddAnswer = true, + question = "What type of food should we have?", + answers = persistentListOf( + Answer("Italian \uD83C\uDDEE\uD83C\uDDF9", false), + Answer("Chinese \uD83C\uDDE8\uD83C\uDDF3", false), + ), + showConfirmation = true, + pollKind = PollKind.Undisclosed, + ), + CreatePollState( + canCreate = true, + canAddAnswer = true, + question = "What type of food should we have?", + answers = persistentListOf( + Answer("Italian \uD83C\uDDEE\uD83C\uDDF9", true), + Answer("Chinese \uD83C\uDDE8\uD83C\uDDF3", true), + Answer("Brazilian \uD83C\uDDE7\uD83C\uDDF7", true), + Answer("French \uD83C\uDDEB\uD83C\uDDF7", true), + ), + showConfirmation = false, + pollKind = PollKind.Undisclosed, + ), + CreatePollState( + canCreate = true, + canAddAnswer = false, + question = "Should there be more than 20 answers?", + answers = persistentListOf( + Answer("1", true), + Answer("2", true), + Answer("3", true), + Answer("4", true), + Answer("5", true), + Answer("6", true), + Answer("7", true), + Answer("8", true), + Answer("9", true), + Answer("10", true), + Answer("11", true), + Answer("12", true), + Answer("13", true), + Answer("14", true), + Answer("15", true), + Answer("16", true), + Answer("17", true), + Answer("18", true), + Answer("19", true), + Answer("20", true), + ), + showConfirmation = false, + pollKind = PollKind.Undisclosed, + ), + CreatePollState( + canCreate = true, + canAddAnswer = true, + question = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." + + " Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor" + + " in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt" + + " in culpa qui officia deserunt mollit anim id est laborum.", + answers = persistentListOf( + Answer( + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." + + " Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis a.", + false + ), + Answer( + "Laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore" + + " eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mol.", + false + ), + ), + showConfirmation = false, + pollKind = PollKind.Undisclosed, + ) + ) +} diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollView.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollView.kt new file mode 100644 index 0000000000..3e3ec4eb16 --- /dev/null +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollView.kt @@ -0,0 +1,187 @@ +/* + * 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 + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.features.poll.impl.R +import io.element.android.libraries.designsystem.VectorIcons +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.DayNightPreviews +import io.element.android.libraries.designsystem.preview.ElementPreview +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 +import io.element.android.libraries.designsystem.theme.components.IconSource +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.ListItemStyle +import io.element.android.libraries.designsystem.theme.components.OutlinedTextField +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TextButton +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.matrix.api.poll.PollKind +import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.ui.strings.CommonStrings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CreatePollView( + state: CreatePollState, + modifier: Modifier = Modifier, +) { + val navBack = { state.eventSink(CreatePollEvents.ConfirmNavBack) } + BackHandler(onBack = navBack) + if (state.showConfirmation) ConfirmationDialog( + content = stringResource(id = R.string.screen_create_poll_confirmation), + onSubmitClicked = { state.eventSink(CreatePollEvents.NavBack) }, + onDismiss = { state.eventSink(CreatePollEvents.HideConfirmation) } + ) + 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, + ) + } + ) + }, + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .padding(paddingValues) + .consumeWindowInsets(paddingValues) + .imePadding() + .fillMaxSize(), + ) { + item { + Text( + text = stringResource(id = R.string.screen_create_poll_question_desc), + modifier = Modifier.padding(start = 32.dp), + style = ElementTheme.typography.fontBodyMdRegular, + ) + } + item { + ListItem( + headlineContent = { + OutlinedTextField( + value = state.question, + onValueChange = { + state.eventSink(CreatePollEvents.SetQuestion(it)) + }, + modifier = Modifier.fillMaxWidth(), + placeholder = { + Text(text = stringResource(id = R.string.screen_create_poll_question_hint)) + }, + ) + } + ) + } + itemsIndexed(state.answers) { index, answer -> + ListItem( + headlineContent = { + OutlinedTextField( + value = answer.text, + onValueChange = { + state.eventSink(CreatePollEvents.SetAnswer(index, it)) + }, + modifier = Modifier.fillMaxWidth(), + placeholder = { + Text(text = stringResource(id = R.string.screen_create_poll_answer_hint, index + 1)) + }, + ) + }, + trailingContent = ListItemContent.Custom { + Icon( + resourceId = VectorIcons.Delete, + contentDescription = null, + modifier = Modifier.clickable(answer.canDelete) { + state.eventSink(CreatePollEvents.RemoveAnswer(index)) + }, + ) + }, + style = if (answer.canDelete) ListItemStyle.Destructive else ListItemStyle.Default, + ) + } + if (state.canAddAnswer) { + item { + ListItem( + headlineContent = { Text(text = stringResource(id = R.string.screen_create_poll_add_option_btn)) }, + leadingContent = ListItemContent.Icon( + iconSource = IconSource.Vector(Icons.Default.Add), + ), + style = ListItemStyle.Primary, + onClick = { state.eventSink(CreatePollEvents.AddAnswer) }, + ) + } + } + item { + HorizontalDivider() + } + item { + ListItem( + headlineContent = { Text(text = stringResource(id = R.string.screen_create_poll_anonymous_headline)) }, + supportingContent = { Text(text = stringResource(id = R.string.screen_create_poll_anonymous_desc)) }, + trailingContent = ListItemContent.Switch( + checked = state.pollKind == PollKind.Undisclosed, + onChange = { state.eventSink(CreatePollEvents.SetPollKind(if (it) PollKind.Undisclosed else PollKind.Disclosed)) }, + ), + ) + } + } + } +} + +@DayNightPreviews +@Composable +internal fun CreatePollViewPreview( + @PreviewParameter(CreatePollStateProvider::class) state: CreatePollState +) = ElementPreview { + CreatePollView( + state = state, + ) +} diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/DefaultPollEntryPoint.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/DefaultCreatePollEntryPoint.kt similarity index 55% rename from features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/DefaultPollEntryPoint.kt rename to features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/DefaultCreatePollEntryPoint.kt index 052c1bcd5f..1ce64deb88 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/DefaultPollEntryPoint.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/DefaultCreatePollEntryPoint.kt @@ -14,33 +14,19 @@ * limitations under the License. */ -package io.element.android.features.poll.impl +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.PollEntryPoint +import io.element.android.features.poll.api.create.CreatePollEntryPoint import io.element.android.libraries.architecture.createNode import io.element.android.libraries.di.AppScope import javax.inject.Inject @ContributesBinding(AppScope::class) -class DefaultPollEntryPoint @Inject constructor() : PollEntryPoint { - - override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): PollEntryPoint.NodeBuilder { - val plugins = ArrayList() - - return object : PollEntryPoint.NodeBuilder { - - override fun callback(callback: PollEntryPoint.Callback): PollEntryPoint.NodeBuilder { - plugins += callback - return this - } - - override fun build(): Node { - return parentNode.createNode(buildContext, plugins) - } - } +class DefaultCreatePollEntryPoint @Inject constructor() : CreatePollEntryPoint { + override fun createNode(parentNode: Node, buildContext: BuildContext): Node { + return parentNode.createNode(buildContext) } } diff --git a/features/poll/impl/src/main/res/values/localazy.xml b/features/poll/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000000..846b247108 --- /dev/null +++ b/features/poll/impl/src/main/res/values/localazy.xml @@ -0,0 +1,11 @@ + + + "Add option" + "Show results only after poll ends" + "Anonymous Poll" + "Option %1$d" + "Are you sure you would like to go back?" + "Question or topic" + "What is the poll about?" + "Create Poll" + diff --git a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenterTest.kt b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenterTest.kt new file mode 100644 index 0000000000..9dacc6062d --- /dev/null +++ b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenterTest.kt @@ -0,0 +1,239 @@ +/* + * 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 + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth +import io.element.android.libraries.matrix.api.poll.PollKind +import io.element.android.libraries.matrix.test.room.CreatePollInvocation +import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class CreatePollPresenterTest { + + private var navUpInvocationsCount = 0 + private val fakeMatrixRoom = FakeMatrixRoom() + // private val fakeAnalyticsService = FakeAnalyticsService() // TODO Polls: add analytics + + private val presenter = CreatePollPresenter( + room = fakeMatrixRoom, + // analyticsService = fakeAnalyticsService, // TODO Polls: add analytics + navigateUp = { navUpInvocationsCount++ }, + ) + + @Test + fun `default state has proper default values`() = runTest { + 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) + } + } + } + + @Test + fun `non blank question and 2 answers are required to create a poll`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initial = awaitItem() + Truth.assertThat(initial.canCreate).isEqualTo(false) + + initial.eventSink(CreatePollEvents.SetQuestion("A question?")) + val questionSet = awaitItem() + Truth.assertThat(questionSet.canCreate).isEqualTo(false) + + questionSet.eventSink(CreatePollEvents.SetAnswer(0, "Answer 1")) + val answer1Set = awaitItem() + Truth.assertThat(answer1Set.canCreate).isEqualTo(false) + + answer1Set.eventSink(CreatePollEvents.SetAnswer(1, "Answer 2")) + val answer2Set = awaitItem() + Truth.assertThat(answer2Set.canCreate).isEqualTo(true) + } + } + + @Test + fun `create polls sends a poll start event`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initial = awaitItem() + initial.eventSink(CreatePollEvents.SetQuestion("A question?")) + initial.eventSink(CreatePollEvents.SetAnswer(0, "Answer 1")) + initial.eventSink(CreatePollEvents.SetAnswer(1, "Answer 2")) + skipItems(3) + initial.eventSink(CreatePollEvents.Create) + delay(1) // Wait for the coroutine to finish + Truth.assertThat(fakeMatrixRoom.createPollInvocations.size).isEqualTo(1) + Truth.assertThat(fakeMatrixRoom.createPollInvocations.last()).isEqualTo( + CreatePollInvocation( + question = "A question?", + answers = listOf("Answer 1", "Answer 2"), + maxSelections = 1, + pollKind = PollKind.Disclosed + ) + ) + } + } + + @Test + fun `add answer button adds an empty answer and removing it removes it`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initial = awaitItem() + Truth.assertThat(initial.answers.size).isEqualTo(2) + + initial.eventSink(CreatePollEvents.AddAnswer) + val answerAdded = awaitItem() + Truth.assertThat(answerAdded.answers.size).isEqualTo(3) + Truth.assertThat(answerAdded.answers[2].text).isEqualTo("") + + initial.eventSink(CreatePollEvents.RemoveAnswer(2)) + val answerRemoved = awaitItem() + Truth.assertThat(answerRemoved.answers.size).isEqualTo(2) + } + } + + @Test + fun `set question sets the question`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initial = awaitItem() + initial.eventSink(CreatePollEvents.SetQuestion("A question?")) + val questionSet = awaitItem() + Truth.assertThat(questionSet.question).isEqualTo("A question?") + } + } + + @Test + fun `set poll answer sets the given poll answer`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initial = awaitItem() + initial.eventSink(CreatePollEvents.SetAnswer(0, "This is answer 1")) + val answerSet = awaitItem() + Truth.assertThat(answerSet.answers.first().text).isEqualTo("This is answer 1") + } + } + + @Test + fun `set poll kind sets the poll kind`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initial = awaitItem() + initial.eventSink(CreatePollEvents.SetPollKind(PollKind.Undisclosed)) + val kindSet = awaitItem() + Truth.assertThat(kindSet.pollKind).isEqualTo(PollKind.Undisclosed) + } + } + + @Test + fun `can add options when between 2 and 20 and then no more`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initial = awaitItem() + Truth.assertThat(initial.canAddAnswer).isEqualTo(true) + repeat(17) { + initial.eventSink(CreatePollEvents.AddAnswer) + Truth.assertThat(awaitItem().canAddAnswer).isEqualTo(true) + } + initial.eventSink(CreatePollEvents.AddAnswer) + Truth.assertThat(awaitItem().canAddAnswer).isEqualTo(false) + } + } + + @Test + fun `can delete option if there are more than 2`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initial = awaitItem() + Truth.assertThat(initial.answers.all { it.canDelete }).isEqualTo(false) + initial.eventSink(CreatePollEvents.AddAnswer) + Truth.assertThat(awaitItem().answers.all { it.canDelete }).isEqualTo(true) + } + } + + @Test + fun `option with more than 240 char is truncated`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initial = awaitItem() + initial.eventSink(CreatePollEvents.SetAnswer(0, "A".repeat(241))) + Truth.assertThat(awaitItem().answers.first().text.length).isEqualTo(240) + } + } + + @Test + fun `navBack event calls navBack lambda`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initial = awaitItem() + Truth.assertThat(navUpInvocationsCount).isEqualTo(0) + initial.eventSink(CreatePollEvents.NavBack) + Truth.assertThat(navUpInvocationsCount).isEqualTo(1) + } + } + + @Test + fun `confirm nav back with blank fields calls nav back lambda`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initial = awaitItem() + Truth.assertThat(navUpInvocationsCount).isEqualTo(0) + Truth.assertThat(initial.showConfirmation).isEqualTo(false) + 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 { + 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) + initial.eventSink(CreatePollEvents.ConfirmNavBack) + Truth.assertThat(navUpInvocationsCount).isEqualTo(0) + Truth.assertThat(awaitItem().showConfirmation).isEqualTo(true) + initial.eventSink(CreatePollEvents.HideConfirmation) + Truth.assertThat(awaitItem().showConfirmation).isEqualTo(false) + } + } +} diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt index ad7f58a239..46ccc7fc56 100755 --- a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt @@ -268,12 +268,13 @@ class DefaultBugReporter @Inject constructor( } } - if (!uploadedSomeLogs) { - error("Couldn't upload any logs") - } - mBugReportFiles.addAll(gzippedFiles) + if (gzippedFiles.isNotEmpty() && !uploadedSomeLogs) { + serverError = "Couldn't upload any logs, please retry." + return@withContext + } + if (withScreenshot) { screenshotHolder.getFileUri() ?.toUri() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c4506bd303..735e9f6f4a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,8 +4,8 @@ [versions] # Project android_gradle_plugin = "8.1.1" -kotlin = "1.9.0" -ksp = "1.9.0-1.0.13" +kotlin = "1.9.10" +ksp = "1.9.10-1.0.13" molecule = "1.2.0" # AndroidX @@ -23,7 +23,7 @@ browser = "1.6.0" # Compose compose_bom = "2023.08.00" -composecompiler = "1.5.1" +composecompiler = "1.5.3" # Coroutines coroutines = "1.7.3" diff --git a/libraries/designsystem/build.gradle.kts b/libraries/designsystem/build.gradle.kts index 35b7a65cb4..f6eacf8746 100644 --- a/libraries/designsystem/build.gradle.kts +++ b/libraries/designsystem/build.gradle.kts @@ -43,5 +43,11 @@ android { ksp(libs.showkase.processor) kspTest(libs.showkase.processor) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ClickableLinkText.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ClickableLinkText.kt index 39838a218a..17e6eec258 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ClickableLinkText.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ClickableLinkText.kt @@ -24,7 +24,6 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.PressInteraction import androidx.compose.foundation.text.InlineTextContent import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -79,8 +78,8 @@ fun ClickableLinkText( @Composable fun ClickableLinkText( annotatedString: AnnotatedString, - interactionSource: MutableInteractionSource, modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, linkify: Boolean = true, linkAnnotationTag: String = LINK_TAG, onClick: () -> Unit = {}, @@ -136,7 +135,6 @@ fun ClickableLinkText( layoutResult.value = it }, inlineContent = inlineContent, - color = MaterialTheme.colorScheme.primary, ) } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/text/AnnotatedStrings.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/text/AnnotatedStrings.kt index 779b7e7053..04a872a1cf 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/text/AnnotatedStrings.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/text/AnnotatedStrings.kt @@ -59,6 +59,7 @@ fun String.toAnnotatedString(): AnnotatedString = buildAnnotatedString { * @param color the color to apply to the string * @param underline whether to underline the string * @param bold whether to bold the string + * @param tagAndLink an optional pair of tag and link to add to the styled part of the string, as StringAnnotation */ @Composable fun buildAnnotatedStringWithStyledPart( @@ -67,6 +68,7 @@ fun buildAnnotatedStringWithStyledPart( color: Color = LinkColor, underline: Boolean = true, bold: Boolean = false, + tagAndLink: Pair? = null, ) = buildAnnotatedString { val coloredPart = stringResource(coloredTextRes) val fullText = stringResource(fullTextRes, coloredPart) @@ -81,6 +83,14 @@ fun buildAnnotatedStringWithStyledPart( start = startIndex, end = startIndex + coloredPart.length, ) + if (tagAndLink != null) { + addStringAnnotation( + tag = tagAndLink.first, + annotation = tagAndLink.second, + start = startIndex, + end = startIndex + coloredPart.length + ) + } } /** diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ListSection.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ListSection.kt index ff58f1e2e0..cab1d493fc 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ListSection.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ListSection.kt @@ -22,7 +22,6 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.outlined.Share import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle @@ -30,9 +29,11 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.ExperimentalTextApi import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.components.ClickableLinkText import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.PreviewGroup @@ -103,17 +104,21 @@ fun ListSupportingText( * @param modifier The modifier to be applied to the text. * @param contentPadding The padding to apply to the text. Default is [ListSupportingTextDefaults.Padding.Default]. */ +@OptIn(ExperimentalTextApi::class) @Composable fun ListSupportingText( annotatedString: AnnotatedString, modifier: Modifier = Modifier, contentPadding: ListSupportingTextDefaults.Padding = ListSupportingTextDefaults.Padding.Default, ) { - Text( - text = annotatedString, - modifier = modifier.padding(contentPadding.paddingValues()), - style = ElementTheme.typography.fontBodySmRegular, - color = ElementTheme.colors.textSecondary, + val style = ElementTheme.typography.fontBodySmRegular + .copy(color = ElementTheme.colors.textSecondary) + val paddedModifier = modifier.padding(contentPadding.paddingValues()) + ClickableLinkText( + annotatedString = annotatedString, + modifier = paddedModifier, + style = style, + linkify = false, ) } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Snackbar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Snackbar.kt index c689d7e547..9458bf748a 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Snackbar.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Snackbar.kt @@ -34,33 +34,40 @@ import androidx.compose.ui.unit.dp import io.element.android.libraries.designsystem.components.button.ButtonVisuals import io.element.android.libraries.designsystem.theme.components.IconSource import io.element.android.libraries.designsystem.theme.components.Snackbar +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock +import java.util.concurrent.atomic.AtomicBoolean /** * A global dispatcher of [SnackbarMessage] to be displayed in [Snackbar] via a [SnackbarHostState]. */ class SnackbarDispatcher { - private val mutex = Mutex() - - private val _snackbarMessage = MutableStateFlow(null) - val snackbarMessage: Flow = _snackbarMessage.asStateFlow() - - suspend fun post(message: SnackbarMessage) { - mutex.withLock { - _snackbarMessage.update { message } + private val queueMutex = Mutex() + private val snackBarMessageQueue = ArrayDeque() + val snackbarMessage: Flow = flow { + while (currentCoroutineContext().isActive) { + queueMutex.lock() + emit(snackBarMessageQueue.firstOrNull()) } } - suspend fun clear() { - mutex.withLock { - _snackbarMessage.update { null } + suspend fun post(message: SnackbarMessage) { + if (snackBarMessageQueue.isEmpty()) { + snackBarMessageQueue.add(message) + if (queueMutex.isLocked) queueMutex.unlock() + } else { + snackBarMessageQueue.add(message) + } + } + + fun clear() { + if (snackBarMessageQueue.isNotEmpty()) { + snackBarMessageQueue.removeFirstOrNull() + if (queueMutex.isLocked) queueMutex.unlock() } } } @@ -87,31 +94,51 @@ fun SnackbarHost(hostState: SnackbarHostState, modifier: Modifier = Modifier) { } } +/** + * Helper method to display a [SnackbarMessage] in a [SnackbarHostState] handling cancellations. + */ @Composable fun rememberSnackbarHostState(snackbarMessage: SnackbarMessage?): SnackbarHostState { val snackbarHostState = remember { SnackbarHostState() } val snackbarMessageText = snackbarMessage?.let { stringResource(id = snackbarMessage.messageResId) - } + } ?: return snackbarHostState + val dispatcher = LocalSnackbarDispatcher.current - LaunchedEffect(snackbarMessage) { - if (snackbarMessageText == null) return@LaunchedEffect - launch { - snackbarHostState.showSnackbar( - message = snackbarMessageText, - duration = snackbarMessage.duration, - ) - if (isActive) { + LaunchedEffect(snackbarMessageText) { + // If the message wasn't already displayed, do it now, and mark it as displayed + // This will prevent the message from appearing in any other active SnackbarHosts + if (snackbarMessage.isDisplayed.getAndSet(true) == false) { + try { + snackbarHostState.showSnackbar( + message = snackbarMessageText, + duration = snackbarMessage.duration, + ) + // The snackbar item was displayed and dismissed, clear its message dispatcher.clear() + } catch (e: CancellationException) { + // The snackbar was being displayed when the coroutine was cancelled, + // so we need to clear its message + dispatcher.clear() + throw e } } } return snackbarHostState } +/** + * A message to be displayed in a [Snackbar]. + * @param messageResId The message to be displayed. + * @param duration The duration of the message. The default value is [SnackbarDuration.Short]. + * @param actionResId The action text to be displayed. The default value is `null`. + * @param isDisplayed Used to track if the current message is already displayed or not. + * @param action The action to be performed when the action is clicked. + */ data class SnackbarMessage( @StringRes val messageResId: Int, val duration: SnackbarDuration = SnackbarDuration.Short, @StringRes val actionResId: Int? = null, + val isDisplayed: AtomicBoolean = AtomicBoolean(false), val action: () -> Unit = {}, ) diff --git a/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/utils/SnackbarDispatcherTests.kt b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/utils/SnackbarDispatcherTests.kt new file mode 100644 index 0000000000..3eb644d800 --- /dev/null +++ b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/utils/SnackbarDispatcherTests.kt @@ -0,0 +1,91 @@ +/* + * 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.libraries.designsystem.utils + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class SnackbarDispatcherTests { + + @Test + fun `given an empty queue the flow emits a null item`() = runTest { + val snackbarDispatcher = SnackbarDispatcher() + snackbarDispatcher.snackbarMessage.test { + assertThat(awaitItem()).isNull() + } + } + + @Test + fun `given an empty queue calling clear does nothing`() = runTest { + val snackbarDispatcher = SnackbarDispatcher() + snackbarDispatcher.snackbarMessage.test { + assertThat(awaitItem()).isNull() + snackbarDispatcher.clear() + expectNoEvents() + } + } + + @Test + fun `given a non-empty queue the flow emits an item`() = runTest { + val snackbarDispatcher = SnackbarDispatcher() + snackbarDispatcher.snackbarMessage.test { + snackbarDispatcher.post(SnackbarMessage(0)) + val result = expectMostRecentItem() + assertThat(result).isNotNull() + } + } + + @Test + fun `given a call to clear, the current message is cleared`() = runTest { + val snackbarDispatcher = SnackbarDispatcher() + snackbarDispatcher.snackbarMessage.test { + snackbarDispatcher.post(SnackbarMessage(0)) + val item = expectMostRecentItem() + assertThat(item).isNotNull() + snackbarDispatcher.clear() + assertThat(awaitItem()).isNull() + } + } + + @Test + fun `given 2 message emissions, the next message is displayed only after a call to clear`() = runTest { + val snackbarDispatcher = SnackbarDispatcher() + snackbarDispatcher.snackbarMessage.test { + val messageA = SnackbarMessage(0) + val messageB = SnackbarMessage(1) + + // Send message A - it is the most recent item + snackbarDispatcher.post(messageA) + assertThat(expectMostRecentItem()).isEqualTo(messageA) + + // Send message B - message A is still the most recent item + snackbarDispatcher.post(messageB) + expectNoEvents() + + // Clear the last message - message B is now the most recent item + snackbarDispatcher.clear() + assertThat(expectMostRecentItem()).isEqualTo(messageB) + + // Clear again - the queue is empty + snackbarDispatcher.clear() + assertThat(awaitItem()).isNull() + } + } + +} diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt index 176bacb2c4..e1f4b740b0 100644 --- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt +++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt @@ -29,7 +29,7 @@ enum class FeatureFlags( Polls( key = "feature.polls", title = "Polls", - description = "Render poll events in the timeline", + description = "Create poll and render poll events in the timeline", defaultValue = false, ) } diff --git a/libraries/matrix/api/build.gradle.kts b/libraries/matrix/api/build.gradle.kts index a1d508885a..1ffe07eb6f 100644 --- a/libraries/matrix/api/build.gradle.kts +++ b/libraries/matrix/api/build.gradle.kts @@ -18,7 +18,7 @@ plugins { id("io.element.android-library") id("kotlin-parcelize") alias(libs.plugins.anvil) - kotlin("plugin.serialization") version "1.9.0" + kotlin("plugin.serialization") version "1.9.10" } android { diff --git a/libraries/matrix/impl/build.gradle.kts b/libraries/matrix/impl/build.gradle.kts index e29d4b73db..b2a21f26df 100644 --- a/libraries/matrix/impl/build.gradle.kts +++ b/libraries/matrix/impl/build.gradle.kts @@ -17,7 +17,7 @@ plugins { id("io.element.android-library") alias(libs.plugins.anvil) - kotlin("plugin.serialization") version "1.9.0" + kotlin("plugin.serialization") version "1.9.10" } android { diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt index 677774afe4..f6b6e1645f 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt @@ -30,6 +30,14 @@ const val A_PASSWORD = "password" val A_USER_ID = UserId("@alice:server.org") val A_USER_ID_2 = UserId("@bob:server.org") +val A_USER_ID_3 = UserId("@carol:server.org") +val A_USER_ID_4 = UserId("@david:server.org") +val A_USER_ID_5 = UserId("@eve:server.org") +val A_USER_ID_6 = UserId("@justin:server.org") +val A_USER_ID_7 = UserId("@mallory:server.org") +val A_USER_ID_8 = UserId("@susie:server.org") +val A_USER_ID_9 = UserId("@victor:server.org") +val A_USER_ID_10 = UserId("@walter:server.org") val A_SESSION_ID: SessionId = A_USER_ID val A_SESSION_ID_2: SessionId = A_USER_ID_2 val A_SPACE_ID = SpaceId("!aSpaceId:domain") diff --git a/libraries/push/impl/build.gradle.kts b/libraries/push/impl/build.gradle.kts index 30a686cad6..b961146e78 100644 --- a/libraries/push/impl/build.gradle.kts +++ b/libraries/push/impl/build.gradle.kts @@ -16,7 +16,7 @@ plugins { id("io.element.android-library") alias(libs.plugins.anvil) - kotlin("plugin.serialization") version "1.9.0" + kotlin("plugin.serialization") version "1.9.10" } android { diff --git a/libraries/pushproviders/unifiedpush/build.gradle.kts b/libraries/pushproviders/unifiedpush/build.gradle.kts index a6565c25f0..28501b2fad 100644 --- a/libraries/pushproviders/unifiedpush/build.gradle.kts +++ b/libraries/pushproviders/unifiedpush/build.gradle.kts @@ -16,7 +16,7 @@ plugins { id("io.element.android-library") alias(libs.plugins.anvil) - kotlin("plugin.serialization") version "1.9.0" + kotlin("plugin.serialization") version "1.9.10" } android { diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.analytics.api.preferences_null_AnalyticsPreferencesViewDark_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.analytics.api.preferences_null_AnalyticsPreferencesViewDark_0_null_0,NEXUS_5,1.0,en].png index 16de0b9378..48e393db15 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.analytics.api.preferences_null_AnalyticsPreferencesViewDark_0_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.analytics.api.preferences_null_AnalyticsPreferencesViewDark_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a08d5ff1f91c0db7c502882815d36d6a675567ebf8c3eddc0ebff431e3592e67 -size 23390 +oid sha256:b365229cac3351e4ec44979b7a22fcae090a8995e4784d9114cfd0033a242510 +size 23147 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.analytics.api.preferences_null_AnalyticsPreferencesViewLight_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.analytics.api.preferences_null_AnalyticsPreferencesViewLight_0_null_0,NEXUS_5,1.0,en].png index 7d6d55ba11..5790bbb52e 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.analytics.api.preferences_null_AnalyticsPreferencesViewLight_0_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.analytics.api.preferences_null_AnalyticsPreferencesViewLight_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6a5300ac57d9d0137cf82af4ed4bd86c3ab7f3a70d2560954e524b7b3c120199 -size 23515 +oid sha256:5fc43706603ce52fa3495fa4e6dfa9aa684540a9dec996a5f705427d34ffb55c +size 23274 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.messagecomposer_null_AttachmentSourcePickerMenu-D-1_2_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.messagecomposer_null_AttachmentSourcePickerMenu-D-1_2_null,NEXUS_5,1.0,en].png index 533e7086c5..58ebf07374 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.messagecomposer_null_AttachmentSourcePickerMenu-D-1_2_null,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.messagecomposer_null_AttachmentSourcePickerMenu-D-1_2_null,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:04978db52b7be7d8aee3ac4aad1ec89ed4f8d9436fbd1829ec60c485e3fe8639 -size 21533 +oid sha256:79aeef6875265e119c3b4b97cea4d36ba3354ae52c4b94b69bbc09461b7bc319 +size 22259 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.messagecomposer_null_AttachmentSourcePickerMenu-N-1_3_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.messagecomposer_null_AttachmentSourcePickerMenu-N-1_3_null,NEXUS_5,1.0,en].png index 7d03ec4b37..6e91b56f10 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.messagecomposer_null_AttachmentSourcePickerMenu-N-1_3_null,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.messagecomposer_null_AttachmentSourcePickerMenu-N-1_3_null,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:52ce35020e0be63a86ad8b82f04b39e27b5960f7ae26a9ac5e1158884054608e -size 19859 +oid sha256:8dafa9a97ebc77f00fdb0432c7b94272f6ea1873c3475353be47ecde95e8b057 +size 20670 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.poll.impl.create_null_CreatePollView-D-0_1_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.poll.impl.create_null_CreatePollView-D-0_1_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f60c0c7a4d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.poll.impl.create_null_CreatePollView-D-0_1_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6b06ec4a259dfccec114689bff7d53089bb7fc64758af23372938fd83c422071 +size 35374 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.poll.impl.create_null_CreatePollView-D-0_1_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.poll.impl.create_null_CreatePollView-D-0_1_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c53d96b0e5 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.poll.impl.create_null_CreatePollView-D-0_1_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:72bb304299954abac15f9487feab06649c0151c41cbcbcbf9c887417224d499b +size 39756 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.poll.impl.create_null_CreatePollView-D-0_1_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.poll.impl.create_null_CreatePollView-D-0_1_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..cc49158545 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.poll.impl.create_null_CreatePollView-D-0_1_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:155cee63d3e031c912786b95bcc8e28dc4d1b296f8f0e4c01bd96398bb2dd040 +size 40623 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.poll.impl.create_null_CreatePollView-D-0_1_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.poll.impl.create_null_CreatePollView-D-0_1_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..9de6f34f78 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.poll.impl.create_null_CreatePollView-D-0_1_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1bb6afbfd69bf254a2d222deb72d80c7f0bb4fc44bc5010a7e34f2b82420a423 +size 47529 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.poll.impl.create_null_CreatePollView-D-0_1_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.poll.impl.create_null_CreatePollView-D-0_1_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..536ea963c9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.poll.impl.create_null_CreatePollView-D-0_1_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2aca4289813d6dbce44fc19bc5e0f7f1dd9e670db4e31c5be93a9d0eac125fff +size 28696 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.poll.impl.create_null_CreatePollView-D-0_1_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.poll.impl.create_null_CreatePollView-D-0_1_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..29d5f43751 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.poll.impl.create_null_CreatePollView-D-0_1_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e2b68997a63739074bbb0622c2f96af9ad2309e61b4ac246a929d3d5e0134939 +size 124111 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.poll.impl.create_null_CreatePollView-N-0_2_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.poll.impl.create_null_CreatePollView-N-0_2_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..ce95adf2e9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.poll.impl.create_null_CreatePollView-N-0_2_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5f7fbf51ee1d86b1fc7cce949f88bfcb1c7ff7f700304e5a74242c4aa4965fcb +size 33455 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.poll.impl.create_null_CreatePollView-N-0_2_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.poll.impl.create_null_CreatePollView-N-0_2_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..8e11aa2691 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.poll.impl.create_null_CreatePollView-N-0_2_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2c7ca3daff99c6086f114d15dbf0649a4be7ff99a5afdeb6d0e8effda383bfec +size 36968 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.poll.impl.create_null_CreatePollView-N-0_2_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.poll.impl.create_null_CreatePollView-N-0_2_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..127289d30b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.poll.impl.create_null_CreatePollView-N-0_2_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8b682b0025c98d9d37ab8dc67cbc8a637f672ae2bf9e4a26e48209188d612c0c +size 36455 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.poll.impl.create_null_CreatePollView-N-0_2_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.poll.impl.create_null_CreatePollView-N-0_2_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..79faef2ce6 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.poll.impl.create_null_CreatePollView-N-0_2_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0680506590290c4ab5ae299abc51e0806b9a1e06a647971eae7b6a2227b0aba9 +size 44631 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.poll.impl.create_null_CreatePollView-N-0_2_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.poll.impl.create_null_CreatePollView-N-0_2_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..43d4dbb763 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.poll.impl.create_null_CreatePollView-N-0_2_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5b954906d2f4f0bb97f4ae78e246caab98a4d01efe04379ccf4ad79e8ae62310 +size 27091 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.poll.impl.create_null_CreatePollView-N-0_2_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.poll.impl.create_null_CreatePollView-N-0_2_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..4a8356b4a6 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.poll.impl.create_null_CreatePollView-N-0_2_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:528e3f1f4fc9b8c236427882409bc718cd6565816ed137a47ba3dd2c69e103cc +size 108555 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.analytics_null_AnalyticsSettingsViewDark_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.analytics_null_AnalyticsSettingsViewDark_0_null_0,NEXUS_5,1.0,en].png index 7e92a427ab..98964b2b77 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.analytics_null_AnalyticsSettingsViewDark_0_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.analytics_null_AnalyticsSettingsViewDark_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4dd40c1eac2e5f33ce340ce69d8c0f502cd0912a0d511602e1122a6f4d8ae330 -size 25433 +oid sha256:e22dc131e8f1f7461c050e871eaa408895529976ef7445aa6faf09852370df90 +size 25176 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.analytics_null_AnalyticsSettingsViewLight_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.analytics_null_AnalyticsSettingsViewLight_0_null_0,NEXUS_5,1.0,en].png index 4a8255f3ac..48fc8ae23e 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.analytics_null_AnalyticsSettingsViewLight_0_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.analytics_null_AnalyticsSettingsViewLight_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:76dbcb5845565774bd33a192d02a3da0216c34b17e6e1bc1f208cabef9506617 -size 26630 +oid sha256:bc234611dd3df74196467129476c69a0212a4c665f2a94797c175cbd911c2083 +size 26339 diff --git a/tools/localazy/config.json b/tools/localazy/config.json index 97acdf2ab5..4be78689de 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -120,6 +120,12 @@ "screen_welcome_.*", "screen_migration_.*" ] + }, + { + "name": ":features:poll:impl", + "includeRegex": [ + "screen_create_poll_.*" + ] } ] }