Merge branch 'develop' of https://github.com/vector-im/element-x-android into feature/dla/emojibase_integration

This commit is contained in:
David Langley
2023-08-30 12:48:38 +01:00
73 changed files with 1608 additions and 253 deletions

View File

@@ -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")
}
}

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

@@ -0,0 +1 @@
Create poll.

1
changelog.d/1168.bugfix Normal file
View File

@@ -0,0 +1 @@
Bug reporter crashes when 'send logs' is disabled.

1
changelog.d/1177.bugfix Normal file
View File

@@ -0,0 +1 @@
Add missing link to the terms on the analytics setting screen.

1
changelog.d/928.bugfix Normal file
View File

@@ -0,0 +1 @@
Make sure Snackbars are only displayed once.

View File

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

View File

@@ -28,5 +28,6 @@ open class AnalyticsPreferencesStateProvider : PreviewParameterProvider<Analytic
fun aAnalyticsPreferencesState() = AnalyticsPreferencesState(
applicationName = "Element X",
isEnabled = false,
policyUrl = "https://element.io",
eventSink = {}
)

View File

@@ -16,22 +16,21 @@
package io.element.android.features.analytics.api.preferences
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.analytics.api.AnalyticsOptInEvents
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
import io.element.android.libraries.designsystem.components.LINK_TAG
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.theme.LinkColor
import io.element.android.libraries.designsystem.text.buildAnnotatedStringWithStyledPart
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ListSupportingText
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
@@ -43,40 +42,33 @@ fun AnalyticsPreferencesView(
state.eventSink(AnalyticsOptInEvents.EnableAnalytics(isEnabled = isEnabled))
}
val firstPart = stringResource(id = CommonStrings.screen_analytics_settings_help_us_improve, state.applicationName)
val secondPart = buildAnnotatedStringWithColoredPart(
val supportingText = stringResource(
id = CommonStrings.screen_analytics_settings_help_us_improve,
state.applicationName
)
val linkText = buildAnnotatedStringWithStyledPart(
CommonStrings.screen_analytics_settings_read_terms,
CommonStrings.screen_analytics_settings_read_terms_content_link
)
val subtitle = "$firstPart\n\n$secondPart"
PreferenceSwitch(
modifier = modifier,
title = stringResource(id = CommonStrings.screen_analytics_settings_share_data),
subtitle = subtitle,
isChecked = state.isEnabled,
onCheckedChange = ::onEnabledChanged,
switchAlignment = Alignment.Top,
)
}
@Composable
fun buildAnnotatedStringWithColoredPart(
@StringRes fullTextRes: Int,
@StringRes coloredTextRes: Int,
color: Color = LinkColor,
underline: Boolean = true,
) = buildAnnotatedString {
val coloredPart = stringResource(coloredTextRes)
val fullText = stringResource(fullTextRes, coloredPart)
val startIndex = fullText.indexOf(coloredPart)
append(fullText)
addStyle(
style = SpanStyle(
color = color,
textDecoration = if (underline) TextDecoration.Underline else null
), start = startIndex, end = startIndex + coloredPart.length
CommonStrings.screen_analytics_settings_read_terms_content_link,
tagAndLink = LINK_TAG to state.policyUrl,
)
Column(modifier) {
ListItem(
headlineContent = {
Text(stringResource(id = CommonStrings.screen_analytics_settings_share_data))
},
supportingContent = {
Text(supportingText)
},
leadingContent = null,
trailingContent = ListItemContent.Switch(
checked = state.isEnabled,
),
onClick = {
onEnabledChanged(!state.isEnabled)
}
)
ListSupportingText(annotatedString = linkText)
}
}
@Preview
@@ -91,5 +83,7 @@ internal fun AnalyticsPreferencesViewDarkPreview(@PreviewParameter(AnalyticsPref
@Composable
private fun ContentToPreview(state: AnalyticsPreferencesState) {
AnalyticsPreferencesView(state)
AnalyticsPreferencesView(
state = state,
)
}

View File

@@ -18,7 +18,6 @@ package io.element.android.features.analytics.impl
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
@@ -28,7 +27,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Poll
import androidx.compose.material.icons.rounded.Check
@@ -37,7 +36,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.BiasAlignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
@@ -45,6 +43,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.analytics.api.AnalyticsOptInEvents
import io.element.android.features.analytics.api.Config
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.molecules.InfoListItem
@@ -56,7 +55,6 @@ import io.element.android.libraries.designsystem.text.buildAnnotatedStringWithSt
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.ButtonSize
import io.element.android.libraries.designsystem.theme.components.Icon
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.temporaryColorBgSpecial
import io.element.android.libraries.designsystem.utils.LogCompositions
@@ -98,6 +96,8 @@ fun AnalyticsOptInView(
)
}
private const val LINK_TAG = "link"
@Composable
private fun AnalyticsOptInHeader(
state: AnalyticsOptInState,
@@ -114,21 +114,29 @@ private fun AnalyticsOptInHeader(
subTitle = stringResource(id = R.string.screen_analytics_prompt_help_us_improve),
iconImageVector = Icons.Filled.Poll
)
Text(
text = buildAnnotatedStringWithStyledPart(
R.string.screen_analytics_prompt_read_terms,
R.string.screen_analytics_prompt_read_terms_content_link,
color = Color.Unspecified,
underline = false,
bold = true,
),
val text = buildAnnotatedStringWithStyledPart(
R.string.screen_analytics_prompt_read_terms,
R.string.screen_analytics_prompt_read_terms_content_link,
color = Color.Unspecified,
underline = false,
bold = true,
tagAndLink = LINK_TAG to Config.POLICY_LINK,
)
ClickableText(
text = text,
onClick = {
text
.getStringAnnotations(LINK_TAG, it, it)
.firstOrNull()
?.let { _ -> 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,
)
)
}
}

View File

@@ -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
)
}

View File

@@ -39,6 +39,7 @@ class AnalyticsPreferencesPresenterTest {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.isEnabled).isTrue()
assertThat(initialState.policyUrl).isNotEmpty()
}
}

View File

@@ -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 {

View File

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

View File

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

View File

@@ -97,6 +97,7 @@ fun MessagesView(
onUserDataClicked: (UserId) -> Unit,
onPreviewAttachments: (ImmutableList<Attachment>) -> Unit,
onSendLocationClicked: () -> Unit,
onCreatePollClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
LogCompositions(tag = "MessagesScreen", msg = "Root")
@@ -175,6 +176,7 @@ fun MessagesView(
onReactionLongClicked = ::onEmojiReactionLongClicked,
onMoreReactionsClicked = ::onMoreReactionsClicked,
onSendLocationClicked = onSendLocationClicked,
onCreatePollClicked = onCreatePollClicked,
onSwipeToReply = { targetEvent ->
state.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Reply, targetEvent))
},
@@ -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 = {},
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
)
}
}
}

View File

@@ -69,7 +69,6 @@ class TimelineItemContentPollFactory @Inject constructor(
return TimelineItemPollContent(
question = content.question,
answerItems = answerItems,
votes = content.votes,
pollKind = content.kind,
isEnded = isEndedPoll,
)

View File

@@ -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<PollAnswerItem>,
val votes: Map<String, List<UserId>>,
val pollKind: PollKind,
val isEnded: Boolean,
) : TimelineItemEventContent {

View File

@@ -34,6 +34,5 @@ fun aTimelineItemPollContent(): TimelineItemPollContent {
question = "What type of food should we have at the party?",
answerItems = aPollAnswerItemList(),
isEnded = false,
votes = emptyMap(),
)
}

View File

@@ -26,10 +26,14 @@ import io.element.android.features.messages.impl.actionlist.ActionListPresenter
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
import io.element.android.features.poll.api.PollAnswerItem
import io.element.android.libraries.matrix.api.poll.PollAnswer
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.core.aBuildMeta
import kotlinx.collections.immutable.persistentListOf
@@ -369,6 +373,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))

View File

@@ -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<String, List<UserId>> = 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<PollAnswerItem> = 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
)
}
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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<Plugin>,
) : BackstackNode<PollFlowNode.NavTarget>(
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(),
)
}
}

View File

@@ -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
}

View File

@@ -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<Plugin>,
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,
)
}
}

View File

@@ -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<CreatePollState> {
@AssistedFactory
interface Factory {
fun create(backNavigator: () -> Unit): CreatePollPresenter
}
@Composable
override fun present(): CreatePollState {
var question: String by rememberSaveable { mutableStateOf("") }
var answers: List<String> by rememberSaveable() { mutableStateOf(listOf("", "")) }
var pollKind: PollKind by rememberSaveable(saver = pollKindSaver) { mutableStateOf(PollKind.Disclosed) }
var showConfirmation: Boolean by rememberSaveable { mutableStateOf(false) }
val canCreate: Boolean by remember { derivedStateOf { canCreate(question, answers) } }
val canAddAnswer: Boolean by remember { derivedStateOf { canAddAnswer(answers) } }
val immutableAnswers: ImmutableList<Answer> 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<String>
) = question.isNotBlank() && answers.size >= MIN_ANSWERS && answers.all { it.isNotBlank() }
private fun canAddAnswer(answers: List<String>) = answers.size < MAX_ANSWERS
private fun List<String>.toAnswers(): ImmutableList<Answer> {
return map { answer ->
Answer(
text = answer,
canDelete = this.size > MIN_ANSWERS,
)
}.toImmutableList()
}
private val pollKindSaver: Saver<MutableState<PollKind>, Boolean> = Saver(
save = {
when (it.value) {
PollKind.Disclosed -> false
PollKind.Undisclosed -> true
}
},
restore = {
mutableStateOf(
when(it) {
true -> PollKind.Undisclosed
else -> PollKind.Disclosed
}
)
}
)

View File

@@ -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<Answer>,
val pollKind: PollKind,
val showConfirmation: Boolean,
val eventSink: (CreatePollEvents) -> Unit = {},
)
data class Answer(
val text: String,
val canDelete: Boolean,
)

View File

@@ -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<CreatePollState> {
override val values: Sequence<CreatePollState>
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,
)
)
}

View File

@@ -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,
)
}

View File

@@ -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<Plugin>()
return object : PollEntryPoint.NodeBuilder {
override fun callback(callback: PollEntryPoint.Callback): PollEntryPoint.NodeBuilder {
plugins += callback
return this
}
override fun build(): Node {
return parentNode.createNode<PollFlowNode>(buildContext, plugins)
}
}
class DefaultCreatePollEntryPoint @Inject constructor() : CreatePollEntryPoint {
override fun createNode(parentNode: Node, buildContext: BuildContext): Node {
return parentNode.createNode<CreatePollNode>(buildContext)
}
}

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_poll_add_option_btn">"Add option"</string>
<string name="screen_create_poll_anonymous_desc">"Show results only after poll ends"</string>
<string name="screen_create_poll_anonymous_headline">"Anonymous Poll"</string>
<string name="screen_create_poll_answer_hint">"Option %1$d"</string>
<string name="screen_create_poll_confirmation">"Are you sure you would like to go back?"</string>
<string name="screen_create_poll_question_desc">"Question or topic"</string>
<string name="screen_create_poll_question_hint">"What is the poll about?"</string>
<string name="screen_create_poll_title">"Create Poll"</string>
</resources>

View File

@@ -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)
}
}
}

View File

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

View File

@@ -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"

View File

@@ -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)
}
}

View File

@@ -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,
)
}

View File

@@ -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<String, String>? = 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
)
}
}
/**

View File

@@ -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,
)
}

View File

@@ -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<SnackbarMessage?>(null)
val snackbarMessage: Flow<SnackbarMessage?> = _snackbarMessage.asStateFlow()
suspend fun post(message: SnackbarMessage) {
mutex.withLock {
_snackbarMessage.update { message }
private val queueMutex = Mutex()
private val snackBarMessageQueue = ArrayDeque<SnackbarMessage>()
val snackbarMessage: Flow<SnackbarMessage?> = 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 = {},
)

View File

@@ -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()
}
}
}

View File

@@ -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,
)
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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")

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -120,6 +120,12 @@
"screen_welcome_.*",
"screen_migration_.*"
]
},
{
"name": ":features:poll:impl",
"includeRegex": [
"screen_create_poll_.*"
]
}
]
}