mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 12:07:28 +00:00
Merge branch 'release/26.09.1'
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
Main changes in this version: important bug fixes and improvements.
|
||||
Full changelog: https://github.com/element-hq/element-x-android/releases
|
||||
+2
-2
@@ -52,9 +52,9 @@ sealed interface TimelineEvent {
|
||||
*/
|
||||
sealed interface TimelineItemPollEvent : TimelineItemEvent
|
||||
|
||||
data class SelectPollAnswer(
|
||||
data class SendPollResponse(
|
||||
val pollStartId: EventId,
|
||||
val answerId: String
|
||||
val answerIds: List<String>
|
||||
) : TimelineItemPollEvent
|
||||
|
||||
data class EndPoll(
|
||||
|
||||
+5
-3
@@ -201,13 +201,15 @@ class TimelinePresenter(
|
||||
newEventState.value = NewEventState.None
|
||||
}
|
||||
}
|
||||
is TimelineEvent.SelectPollAnswer -> sessionCoroutineScope.launch {
|
||||
is TimelineEvent.SendPollResponse -> sessionCoroutineScope.launch {
|
||||
timelineController.invokeOnCurrentTimeline {
|
||||
sendPollResponseAction.execute(
|
||||
timeline = this,
|
||||
pollStartId = event.pollStartId,
|
||||
answerId = event.answerId
|
||||
)
|
||||
answerIds = event.answerIds,
|
||||
).onFailure { error ->
|
||||
Timber.tag(tag).e(error, "Failed to send poll response")
|
||||
}
|
||||
}
|
||||
}
|
||||
is TimelineEvent.EndPoll -> sessionCoroutineScope.launch {
|
||||
|
||||
+5
-3
@@ -26,8 +26,8 @@ fun TimelineItemPollView(
|
||||
eventSink: (TimelineEvent.TimelineItemPollEvent) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
fun onSelectAnswer(pollStartId: EventId, answerId: String) {
|
||||
eventSink(TimelineEvent.SelectPollAnswer(pollStartId, answerId))
|
||||
fun onSendPollResponse(pollStartId: EventId, answerIds: List<String>) {
|
||||
eventSink(TimelineEvent.SendPollResponse(pollStartId, answerIds))
|
||||
}
|
||||
|
||||
fun onEndPoll(pollStartId: EventId) {
|
||||
@@ -43,10 +43,12 @@ fun TimelineItemPollView(
|
||||
question = content.question,
|
||||
answerItems = content.answerItems.toImmutableList(),
|
||||
pollKind = content.pollKind,
|
||||
isMultipleSelection = content.maxSelections > 1u,
|
||||
maxSelections = content.maxSelections,
|
||||
isPollEnded = content.isEnded,
|
||||
isPollEditable = content.isEditable,
|
||||
isMine = content.isMine,
|
||||
onSelectAnswer = ::onSelectAnswer,
|
||||
onSendPollResponse = ::onSendPollResponse,
|
||||
onEditPoll = ::onEditPoll,
|
||||
onEndPoll = ::onEndPoll,
|
||||
modifier = modifier,
|
||||
|
||||
+1
@@ -33,6 +33,7 @@ class TimelineItemContentPollFactory(
|
||||
question = pollContentState.question,
|
||||
answerItems = pollContentState.answerItems,
|
||||
pollKind = pollContentState.pollKind,
|
||||
maxSelections = pollContentState.maxSelections,
|
||||
isEnded = pollContentState.isPollEnded,
|
||||
isEdited = content.isEdited
|
||||
)
|
||||
|
||||
+1
@@ -19,6 +19,7 @@ data class TimelineItemPollContent(
|
||||
val question: String,
|
||||
val answerItems: List<PollAnswerItem>,
|
||||
val pollKind: PollKind,
|
||||
val maxSelections: ULong,
|
||||
val isEnded: Boolean,
|
||||
override val isEdited: Boolean,
|
||||
) : TimelineItemEventContent,
|
||||
|
||||
+2
@@ -32,12 +32,14 @@ fun aTimelineItemPollContent(
|
||||
isEditable: Boolean = false,
|
||||
isEnded: Boolean = false,
|
||||
isEdited: Boolean = false,
|
||||
maxSelections: ULong = 1u,
|
||||
): TimelineItemPollContent {
|
||||
return TimelineItemPollContent(
|
||||
eventId = EventId("\$anEventId"),
|
||||
pollKind = PollKind.Disclosed,
|
||||
question = question,
|
||||
answerItems = answerItems,
|
||||
maxSelections = maxSelections,
|
||||
isMine = isMine,
|
||||
isEditable = isEditable,
|
||||
isEnded = isEnded,
|
||||
|
||||
+2
-2
@@ -951,14 +951,14 @@ class TimelinePresenterTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - PollAnswerSelected event`() = runTest {
|
||||
fun `present - SendPollResponse event`() = runTest {
|
||||
val sendPollResponseAction = FakeSendPollResponseAction()
|
||||
val presenter = createTimelinePresenter(
|
||||
sendPollResponseAction = sendPollResponseAction,
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.eventSink.invoke(TimelineEvent.SelectPollAnswer(AN_EVENT_ID, "anAnswerId"))
|
||||
initialState.eventSink.invoke(TimelineEvent.SendPollResponse(AN_EVENT_ID, listOf("anAnswerId")))
|
||||
}
|
||||
delay(1)
|
||||
sendPollResponseAction.verifyExecutionCount(1)
|
||||
|
||||
+1
-1
@@ -50,7 +50,7 @@ class TimelineItemPollViewTest : RobolectricTest() {
|
||||
matcher = hasText(answer.text),
|
||||
useUnmergedTree = true,
|
||||
).performClick()
|
||||
eventsRecorder.assertSingle(TimelineEvent.SelectPollAnswer(content.eventId!!, answer.id))
|
||||
eventsRecorder.assertSingle(TimelineEvent.SendPollResponse(content.eventId!!, listOf(answer.id)))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+19
@@ -229,6 +229,25 @@ class TimelineItemContentMessageFactoryTest : RobolectricTest() {
|
||||
assertThat((result as TimelineItemTextContent).formattedBody).isEqualTo(SpannedString("body"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test create TextMessageType with a formatted body containing only an image falls back to the body`() = runTest {
|
||||
val sut = createTimelineItemContentMessageFactory()
|
||||
val result = sut.create(
|
||||
content = createMessageContent(
|
||||
type = TextMessageType(
|
||||
body = "\uD83D\uDE1C",
|
||||
formatted = FormattedBody(MessageFormat.HTML, "<img src=\"mxc://matrix.org/anImage\" alt=\"\uD83D\uDE1C\" />")
|
||||
)
|
||||
),
|
||||
senderId = A_USER_ID,
|
||||
senderProfile = aProfileDetails(),
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
assertThat((result as TimelineItemTextContent).formattedBody).isEqualTo(SpannedString("\uD83D\uDE1C"))
|
||||
assertThat(result.htmlDocument).isNull()
|
||||
assertThat(result.plainText).isEqualTo("\uD83D\uDE1C")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test create VideoMessageType`() = runTest {
|
||||
val sut = createTimelineItemContentMessageFactory()
|
||||
|
||||
+3
-3
@@ -12,17 +12,17 @@ import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
|
||||
/**
|
||||
* Sends the current user's answer to a poll, wrapping the timeline call so the analytics are captured in one place.
|
||||
* Sends the current user's answer(s) to a poll, wrapping the timeline call so the analytics are captured in one place.
|
||||
*/
|
||||
interface SendPollResponseAction {
|
||||
/**
|
||||
* @param timeline the timeline the poll lives in.
|
||||
* @param pollStartId the poll start event being answered.
|
||||
* @param answerId the id of the chosen answer.
|
||||
* @param answerIds the ids of the chosen answers.
|
||||
*/
|
||||
suspend fun execute(
|
||||
timeline: Timeline,
|
||||
pollStartId: EventId,
|
||||
answerId: String
|
||||
answerIds: List<String>,
|
||||
): Result<Unit>
|
||||
}
|
||||
|
||||
+5
-1
@@ -18,6 +18,7 @@ import kotlinx.collections.immutable.ImmutableList
|
||||
* @property question the poll question.
|
||||
* @property answerItems the list of answers.
|
||||
* @property pollKind the kind of poll.
|
||||
* @property maxSelections the maximum number of selections allowed.
|
||||
* @property isPollEditable whether the poll is editable.
|
||||
* @property isPollEnded whether the poll is ended.
|
||||
* @property isMine whether the poll has been created by me.
|
||||
@@ -27,7 +28,10 @@ data class PollContentState(
|
||||
val question: String,
|
||||
val answerItems: ImmutableList<PollAnswerItem>,
|
||||
val pollKind: PollKind,
|
||||
val maxSelections: ULong,
|
||||
val isPollEditable: Boolean,
|
||||
val isPollEnded: Boolean,
|
||||
val isMine: Boolean,
|
||||
)
|
||||
) {
|
||||
val isMultipleSelection = maxSelections > 1u
|
||||
}
|
||||
|
||||
+2
@@ -84,6 +84,7 @@ fun aPollContentState(
|
||||
hasVotes: Boolean = true,
|
||||
question: String = aPollQuestion(),
|
||||
pollKind: PollKind = PollKind.Disclosed,
|
||||
maxSelections: ULong = 1u,
|
||||
answerItems: ImmutableList<PollAnswerItem> = aPollAnswerItemList(
|
||||
isEnded = isEnded,
|
||||
showVotes = showVotes,
|
||||
@@ -94,6 +95,7 @@ fun aPollContentState(
|
||||
question = question,
|
||||
answerItems = answerItems,
|
||||
pollKind = pollKind,
|
||||
maxSelections = maxSelections,
|
||||
isPollEditable = isMine && !isEnded && isPollEditable,
|
||||
isPollEnded = isEnded,
|
||||
isMine = isMine,
|
||||
|
||||
+69
-12
@@ -40,7 +40,7 @@ import kotlinx.collections.immutable.ImmutableList
|
||||
@Composable
|
||||
fun PollContentView(
|
||||
state: PollContentState,
|
||||
onSelectAnswer: (pollStartId: EventId, answerId: String) -> Unit,
|
||||
onSendPollResponse: (pollStartId: EventId, answerIds: List<String>) -> Unit,
|
||||
onEditPoll: (pollStartId: EventId) -> Unit,
|
||||
onEndPoll: (pollStartId: EventId) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -50,11 +50,13 @@ fun PollContentView(
|
||||
question = state.question,
|
||||
answerItems = state.answerItems,
|
||||
pollKind = state.pollKind,
|
||||
isMultipleSelection = state.isMultipleSelection,
|
||||
maxSelections = state.maxSelections,
|
||||
isPollEditable = state.isPollEditable,
|
||||
isPollEnded = state.isPollEnded,
|
||||
isMine = state.isMine,
|
||||
onEditPoll = onEditPoll,
|
||||
onSelectAnswer = onSelectAnswer,
|
||||
onSendPollResponse = onSendPollResponse,
|
||||
onEndPoll = onEndPoll,
|
||||
modifier = modifier,
|
||||
)
|
||||
@@ -66,10 +68,12 @@ fun PollContentView(
|
||||
question: String,
|
||||
answerItems: ImmutableList<PollAnswerItem>,
|
||||
pollKind: PollKind,
|
||||
isMultipleSelection: Boolean,
|
||||
maxSelections: ULong,
|
||||
isPollEditable: Boolean,
|
||||
isPollEnded: Boolean,
|
||||
isMine: Boolean,
|
||||
onSelectAnswer: (pollStartId: EventId, answerId: String) -> Unit,
|
||||
onSendPollResponse: (pollStartId: EventId, answerIds: List<String>) -> Unit,
|
||||
onEditPoll: (pollStartId: EventId) -> Unit,
|
||||
onEndPoll: (pollStartId: EventId) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -77,7 +81,23 @@ fun PollContentView(
|
||||
val votesCount = remember(answerItems) { answerItems.sumOf { it.votesCount } }
|
||||
|
||||
fun onSelectAnswer(pollAnswer: PollAnswer) {
|
||||
eventId?.let { onSelectAnswer(it, pollAnswer.id) }
|
||||
eventId?.let {
|
||||
if (isMultipleSelection) {
|
||||
val newSelections = answerItems.map { item ->
|
||||
if (item.answer.id == pollAnswer.id) {
|
||||
item.copy(isSelected = !item.isSelected)
|
||||
} else {
|
||||
item
|
||||
}
|
||||
}.filter { it.isSelected }.map { it.answer.id }
|
||||
// Only send the response if the selection count is within the limit
|
||||
if (newSelections.size <= maxSelections.toInt()) {
|
||||
onSendPollResponse(it, newSelections)
|
||||
}
|
||||
} else {
|
||||
onSendPollResponse(it, listOf(pollAnswer.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onEditPoll() {
|
||||
@@ -107,7 +127,11 @@ fun PollContentView(
|
||||
) {
|
||||
PollTitleView(title = question, isPollEnded = isPollEnded)
|
||||
|
||||
PollAnswers(answerItems = answerItems, onSelectAnswer = ::onSelectAnswer)
|
||||
PollAnswers(
|
||||
answerItems = answerItems,
|
||||
isMultipleSelection = isMultipleSelection,
|
||||
onSelectAnswer = ::onSelectAnswer,
|
||||
)
|
||||
|
||||
if (isPollEnded || pollKind == PollKind.Disclosed) {
|
||||
DisclosedPollBottomNotice(votesCount = votesCount)
|
||||
@@ -130,8 +154,10 @@ fun PollContentView(
|
||||
@Composable
|
||||
private fun PollAnswers(
|
||||
answerItems: ImmutableList<PollAnswerItem>,
|
||||
isMultipleSelection: Boolean,
|
||||
onSelectAnswer: (PollAnswer) -> Unit,
|
||||
) {
|
||||
val role = if (isMultipleSelection) Role.Checkbox else Role.RadioButton
|
||||
Column(
|
||||
modifier = Modifier.selectableGroup(),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
@@ -144,7 +170,7 @@ private fun PollAnswers(
|
||||
selected = it.isSelected,
|
||||
enabled = it.isEnabled,
|
||||
onClick = { onSelectAnswer(it.answer) },
|
||||
role = Role.RadioButton,
|
||||
role = role,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -207,10 +233,12 @@ internal fun PollContentViewUndisclosedPreview() = ElementPreview {
|
||||
question = "What type of food should we have at the party?",
|
||||
answerItems = aPollAnswerItemList(showVotes = false),
|
||||
pollKind = PollKind.Undisclosed,
|
||||
isMultipleSelection = false,
|
||||
maxSelections = 1u,
|
||||
isPollEnded = false,
|
||||
isPollEditable = false,
|
||||
isMine = false,
|
||||
onSelectAnswer = { _, _ -> },
|
||||
onSendPollResponse = { _, _ -> },
|
||||
onEditPoll = {},
|
||||
onEndPoll = {},
|
||||
)
|
||||
@@ -224,10 +252,31 @@ internal fun PollContentViewDisclosedPreview() = ElementPreview {
|
||||
question = "What type of food should we have at the party?",
|
||||
answerItems = aPollAnswerItemList(),
|
||||
pollKind = PollKind.Disclosed,
|
||||
isMultipleSelection = false,
|
||||
maxSelections = 1u,
|
||||
isPollEnded = false,
|
||||
isPollEditable = false,
|
||||
isMine = false,
|
||||
onSelectAnswer = { _, _ -> },
|
||||
onSendPollResponse = { _, _ -> },
|
||||
onEditPoll = {},
|
||||
onEndPoll = {},
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun PollContentViewMultipleSelectionPreview() = ElementPreview {
|
||||
PollContentView(
|
||||
eventId = EventId("\$anEventId"),
|
||||
question = "What type of food should we have at the party?",
|
||||
answerItems = aPollAnswerItemList(),
|
||||
pollKind = PollKind.Disclosed,
|
||||
isMultipleSelection = true,
|
||||
maxSelections = 3u,
|
||||
isPollEnded = false,
|
||||
isPollEditable = false,
|
||||
isMine = false,
|
||||
onSendPollResponse = { _, _ -> },
|
||||
onEditPoll = {},
|
||||
onEndPoll = {},
|
||||
)
|
||||
@@ -241,10 +290,12 @@ internal fun PollContentViewEndedPreview() = ElementPreview {
|
||||
question = "What type of food should we have at the party?",
|
||||
answerItems = aPollAnswerItemList(isEnded = true),
|
||||
pollKind = PollKind.Disclosed,
|
||||
isMultipleSelection = false,
|
||||
maxSelections = 1u,
|
||||
isPollEnded = true,
|
||||
isPollEditable = false,
|
||||
isMine = false,
|
||||
onSelectAnswer = { _, _ -> },
|
||||
onSendPollResponse = { _, _ -> },
|
||||
onEditPoll = {},
|
||||
onEndPoll = {},
|
||||
)
|
||||
@@ -258,10 +309,12 @@ internal fun PollContentViewCreatorEditablePreview() = ElementPreview {
|
||||
question = "What type of food should we have at the party?",
|
||||
answerItems = aPollAnswerItemList(hasVotes = false, isEnded = false),
|
||||
pollKind = PollKind.Disclosed,
|
||||
isMultipleSelection = false,
|
||||
maxSelections = 1u,
|
||||
isPollEnded = false,
|
||||
isPollEditable = true,
|
||||
isMine = true,
|
||||
onSelectAnswer = { _, _ -> },
|
||||
onSendPollResponse = { _, _ -> },
|
||||
onEditPoll = {},
|
||||
onEndPoll = {},
|
||||
)
|
||||
@@ -275,10 +328,12 @@ internal fun PollContentViewCreatorPreview() = ElementPreview {
|
||||
question = "What type of food should we have at the party?",
|
||||
answerItems = aPollAnswerItemList(isEnded = false),
|
||||
pollKind = PollKind.Disclosed,
|
||||
isMultipleSelection = false,
|
||||
maxSelections = 1u,
|
||||
isPollEnded = false,
|
||||
isPollEditable = false,
|
||||
isMine = true,
|
||||
onSelectAnswer = { _, _ -> },
|
||||
onSendPollResponse = { _, _ -> },
|
||||
onEditPoll = {},
|
||||
onEndPoll = {},
|
||||
)
|
||||
@@ -292,10 +347,12 @@ internal fun PollContentViewCreatorEndedPreview() = ElementPreview {
|
||||
question = "What type of food should we have at the party?",
|
||||
answerItems = aPollAnswerItemList(isEnded = true),
|
||||
pollKind = PollKind.Disclosed,
|
||||
isMultipleSelection = false,
|
||||
maxSelections = 1u,
|
||||
isPollEnded = true,
|
||||
isPollEditable = false,
|
||||
isMine = true,
|
||||
onSelectAnswer = { _, _ -> },
|
||||
onSendPollResponse = { _, _ -> },
|
||||
onEditPoll = {},
|
||||
onEndPoll = {},
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<plurals name="a11y_polls_percent_of_total">
|
||||
<item quantity="other">"占总投票的 %1$d%"</item>
|
||||
<item quantity="other">"占总投票的 %1$d%%"</item>
|
||||
</plurals>
|
||||
<string name="a11y_polls_will_remove_selection">"将移除之前的选择"</string>
|
||||
<string name="a11y_polls_winning_answer">"此为胜出的答案"</string>
|
||||
|
||||
+1
-1
@@ -12,5 +12,5 @@ internal object PollConstants {
|
||||
const val MIN_ANSWERS = 2
|
||||
const val MAX_ANSWERS = 20
|
||||
const val MAX_ANSWER_LENGTH = 240
|
||||
const val MAX_SELECTIONS = 1
|
||||
const val DEFAULT_MAX_SELECTIONS = 1
|
||||
}
|
||||
|
||||
+2
-2
@@ -20,10 +20,10 @@ import io.element.android.services.analytics.api.AnalyticsService
|
||||
class DefaultSendPollResponseAction(
|
||||
private val analyticsService: AnalyticsService,
|
||||
) : SendPollResponseAction {
|
||||
override suspend fun execute(timeline: Timeline, pollStartId: EventId, answerId: String): Result<Unit> {
|
||||
override suspend fun execute(timeline: Timeline, pollStartId: EventId, answerIds: List<String>): Result<Unit> {
|
||||
return timeline.sendPollResponse(
|
||||
pollStartId = pollStartId,
|
||||
answers = listOf(answerId),
|
||||
answers = answerIds,
|
||||
).onSuccess {
|
||||
analyticsService.capture(PollVote())
|
||||
}
|
||||
|
||||
+1
@@ -18,6 +18,7 @@ sealed interface CreatePollEvent {
|
||||
data object AddAnswer : CreatePollEvent
|
||||
data class RemoveAnswer(val index: Int) : CreatePollEvent
|
||||
data class SetPollKind(val pollKind: PollKind) : CreatePollEvent
|
||||
data class SetMaxSelections(val maxSelections: Int) : CreatePollEvent
|
||||
data object NavBack : CreatePollEvent
|
||||
data object ConfirmNavBack : CreatePollEvent
|
||||
data object HideConfirmation : CreatePollEvent
|
||||
|
||||
+7
-2
@@ -24,7 +24,6 @@ import im.vector.app.features.analytics.plan.Composer
|
||||
import im.vector.app.features.analytics.plan.PollCreation
|
||||
import io.element.android.features.messages.api.MessageComposerContext
|
||||
import io.element.android.features.poll.api.create.CreatePollMode
|
||||
import io.element.android.features.poll.impl.PollConstants.MAX_SELECTIONS
|
||||
import io.element.android.features.poll.impl.data.PollRepository
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.matrix.api.poll.PollAnswer
|
||||
@@ -81,6 +80,7 @@ class CreatePollPresenter(
|
||||
question = it.question,
|
||||
answers = it.answers.map(PollAnswer::text).toImmutableList(),
|
||||
isDisclosed = it.kind.isDisclosed,
|
||||
maxSelections = it.maxSelections.toInt(),
|
||||
)
|
||||
initialPoll = loadedPoll
|
||||
poll = loadedPoll
|
||||
@@ -109,7 +109,7 @@ class CreatePollPresenter(
|
||||
question = poll.question,
|
||||
answers = poll.answers,
|
||||
pollKind = poll.pollKind,
|
||||
maxSelections = MAX_SELECTIONS,
|
||||
maxSelections = poll.maxSelections,
|
||||
).onSuccess {
|
||||
analyticsService.capturePollSaved(
|
||||
isUndisclosed = poll.pollKind == PollKind.Undisclosed,
|
||||
@@ -151,6 +151,9 @@ class CreatePollPresenter(
|
||||
is CreatePollEvent.SetPollKind -> {
|
||||
poll = poll.copy(isDisclosed = event.pollKind.isDisclosed)
|
||||
}
|
||||
is CreatePollEvent.SetMaxSelections -> {
|
||||
poll = poll.copy(maxSelections = event.maxSelections)
|
||||
}
|
||||
is CreatePollEvent.SetQuestion -> {
|
||||
poll = poll.copy(question = event.question)
|
||||
}
|
||||
@@ -182,6 +185,8 @@ class CreatePollPresenter(
|
||||
question = poll.question,
|
||||
answers = immutableAnswers,
|
||||
pollKind = poll.pollKind,
|
||||
maxSelections = poll.maxSelections,
|
||||
maxAllowedSelections = poll.answers.size,
|
||||
showBackConfirmation = showBackConfirmation,
|
||||
showDeleteConfirmation = showDeleteConfirmation,
|
||||
eventSink = ::handleEvent,
|
||||
|
||||
+2
@@ -18,6 +18,8 @@ data class CreatePollState(
|
||||
val question: String,
|
||||
val answers: ImmutableList<Answer>,
|
||||
val pollKind: PollKind,
|
||||
val maxSelections: Int,
|
||||
val maxAllowedSelections: Int,
|
||||
val showBackConfirmation: Boolean,
|
||||
val showDeleteConfirmation: Boolean,
|
||||
val eventSink: (CreatePollEvent) -> Unit,
|
||||
|
||||
+19
-1
@@ -150,6 +150,21 @@ class CreatePollStatePreviewParam : PreviewParameterProvider<CreatePollState> {
|
||||
showDeleteConfirmation = true,
|
||||
showBackConfirmation = false,
|
||||
),
|
||||
aCreatePollState(
|
||||
mode = CreatePollState.Mode.New,
|
||||
canCreate = true,
|
||||
canAddAnswer = true,
|
||||
question = "What type of food should we have?",
|
||||
answers = listOf(
|
||||
Answer("Italian \uD83C\uDDEE\uD83C\uDDF9", false),
|
||||
Answer("Chinese \uD83C\uDDE8\uD83C\uDDF3", false),
|
||||
Answer("Brazilian \uD83C\uDDE7\uD83C\uDDF7", true),
|
||||
),
|
||||
showBackConfirmation = false,
|
||||
showDeleteConfirmation = false,
|
||||
pollKind = PollKind.Disclosed,
|
||||
maxSelections = 2,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -161,7 +176,8 @@ private fun aCreatePollState(
|
||||
answers: List<Answer>,
|
||||
showBackConfirmation: Boolean,
|
||||
showDeleteConfirmation: Boolean,
|
||||
pollKind: PollKind
|
||||
pollKind: PollKind,
|
||||
maxSelections: Int = 1,
|
||||
): CreatePollState {
|
||||
return CreatePollState(
|
||||
mode = mode,
|
||||
@@ -172,6 +188,8 @@ private fun aCreatePollState(
|
||||
showBackConfirmation = showBackConfirmation,
|
||||
showDeleteConfirmation = showDeleteConfirmation,
|
||||
pollKind = pollKind,
|
||||
maxSelections = maxSelections,
|
||||
maxAllowedSelections = answers.size,
|
||||
eventSink = {}
|
||||
)
|
||||
}
|
||||
|
||||
+62
@@ -11,11 +11,13 @@ package io.element.android.features.poll.impl.create
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
@@ -25,6 +27,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
@@ -32,6 +35,8 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.poll.impl.R
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
@@ -173,6 +178,17 @@ fun CreatePollView(
|
||||
item {
|
||||
Column {
|
||||
HorizontalDivider()
|
||||
ListItem(
|
||||
content = { Text(text = stringResource(id = R.string.screen_create_poll_max_selections_headline)) },
|
||||
trailingContent = ListItemContent.Custom {
|
||||
MaxSelectionsStepper(
|
||||
current = state.maxSelections,
|
||||
max = state.maxAllowedSelections,
|
||||
onDecrement = { state.eventSink(CreatePollEvent.SetMaxSelections(state.maxSelections - 1)) },
|
||||
onIncrement = { state.eventSink(CreatePollEvent.SetMaxSelections(state.maxSelections + 1)) },
|
||||
)
|
||||
},
|
||||
)
|
||||
ListItem(
|
||||
content = { Text(text = stringResource(id = R.string.screen_create_poll_anonymous_headline)) },
|
||||
supportingContent = { Text(text = stringResource(id = R.string.screen_create_poll_anonymous_desc)) },
|
||||
@@ -229,6 +245,52 @@ private fun CreatePollTopAppBar(
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MaxSelectionsStepper(
|
||||
current: Int,
|
||||
max: Int,
|
||||
onDecrement: () -> Unit,
|
||||
onIncrement: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Minus(),
|
||||
contentDescription = stringResource(id = CommonStrings.a11y_create_poll_votes_allowed_decrease),
|
||||
modifier = Modifier
|
||||
.clickable(enabled = current > 1) { onDecrement() }
|
||||
.size(32.dp)
|
||||
.padding(4.dp),
|
||||
tint = if (current > 1) {
|
||||
ElementTheme.colors.bgAccentRest
|
||||
} else {
|
||||
ElementTheme.colors.iconDisabled
|
||||
},
|
||||
)
|
||||
Text(
|
||||
text = current.toString(),
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
)
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Plus(),
|
||||
contentDescription = stringResource(id = CommonStrings.a11y_create_poll_votes_allowed_increase),
|
||||
modifier = Modifier
|
||||
.clickable(enabled = current < max) { onIncrement() }
|
||||
.size(32.dp)
|
||||
.padding(4.dp),
|
||||
tint = if (current < max) {
|
||||
ElementTheme.colors.bgAccentRest
|
||||
} else {
|
||||
ElementTheme.colors.iconDisabled
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun CreatePollViewPreview(
|
||||
|
||||
+7
-1
@@ -25,12 +25,14 @@ data class PollFormState(
|
||||
val question: String,
|
||||
val answers: ImmutableList<String>,
|
||||
val isDisclosed: Boolean,
|
||||
val maxSelections: Int,
|
||||
) {
|
||||
companion object {
|
||||
val Empty = PollFormState(
|
||||
question = "",
|
||||
answers = MutableList(MIN_ANSWERS) { "" }.toImmutableList(),
|
||||
isDisclosed = true,
|
||||
maxSelections = 1,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -67,7 +69,9 @@ data class PollFormState(
|
||||
return this
|
||||
}
|
||||
|
||||
return copy(answers = answers.filterIndexed { i, _ -> i != index }.toImmutableList())
|
||||
val newAnswers = answers.filterIndexed { i, _ -> i != index }.toImmutableList()
|
||||
val clampedMaxSelections = maxSelections.coerceIn(1, newAnswers.size)
|
||||
return copy(answers = newAnswers, maxSelections = clampedMaxSelections)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -110,6 +114,7 @@ internal val pollFormStateSaver = mapSaver(
|
||||
"question" to it.question,
|
||||
"answers" to it.answers.toTypedArray(),
|
||||
"isDisclosed" to it.isDisclosed,
|
||||
"maxSelections" to it.maxSelections,
|
||||
)
|
||||
},
|
||||
restore = { saved ->
|
||||
@@ -117,6 +122,7 @@ internal val pollFormStateSaver = mapSaver(
|
||||
question = saved["question"] as String,
|
||||
answers = (saved["answers"] as Array<*>).map { it as String }.toImmutableList(),
|
||||
isDisclosed = saved["isDisclosed"] as Boolean,
|
||||
maxSelections = saved["maxSelections"] as Int,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ import io.element.android.libraries.matrix.api.core.EventId
|
||||
|
||||
sealed interface PollHistoryEvent {
|
||||
data object LoadMore : PollHistoryEvent
|
||||
data class SelectPollAnswer(val pollStartId: EventId, val answerId: String) : PollHistoryEvent
|
||||
data class SendPollResponse(val pollStartId: EventId, val answerIds: List<String>) : PollHistoryEvent
|
||||
data class EndPoll(val pollStartId: EventId) : PollHistoryEvent
|
||||
data class SelectFilter(val filter: PollHistoryFilter) : PollHistoryEvent
|
||||
}
|
||||
|
||||
+6
-3
@@ -31,6 +31,7 @@ import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
@Inject
|
||||
class PollHistoryPresenter(
|
||||
@@ -68,12 +69,14 @@ class PollHistoryPresenter(
|
||||
is PollHistoryEvent.LoadMore -> {
|
||||
coroutineScope.loadMore(timeline)
|
||||
}
|
||||
is PollHistoryEvent.SelectPollAnswer -> sessionCoroutineScope.launch {
|
||||
is PollHistoryEvent.SendPollResponse -> sessionCoroutineScope.launch {
|
||||
sendPollResponseAction.execute(
|
||||
timeline = timeline,
|
||||
pollStartId = event.pollStartId,
|
||||
answerId = event.answerId
|
||||
)
|
||||
answerIds = event.answerIds
|
||||
).onFailure { error ->
|
||||
Timber.e(error, "Failed to send poll response")
|
||||
}
|
||||
}
|
||||
is PollHistoryEvent.EndPoll -> sessionCoroutineScope.launch {
|
||||
endPollAction.execute(timeline = timeline, pollStartId = event.pollStartId)
|
||||
|
||||
+7
-7
@@ -65,8 +65,8 @@ fun PollHistoryView(
|
||||
state.eventSink(PollHistoryEvent.LoadMore)
|
||||
}
|
||||
|
||||
fun onSelectAnswer(pollStartId: EventId, answerId: String) {
|
||||
state.eventSink(PollHistoryEvent.SelectPollAnswer(pollStartId, answerId))
|
||||
fun onSendPollResponse(pollStartId: EventId, answerIds: List<String>) {
|
||||
state.eventSink(PollHistoryEvent.SendPollResponse(pollStartId, answerIds))
|
||||
}
|
||||
|
||||
fun onEndPoll(pollStartId: EventId) {
|
||||
@@ -114,7 +114,7 @@ fun PollHistoryView(
|
||||
pollHistoryItems = pollHistoryItems,
|
||||
hasMoreToLoad = state.hasMoreToLoad,
|
||||
isLoading = state.isLoading,
|
||||
onSelectAnswer = ::onSelectAnswer,
|
||||
onSendPollResponse = ::onSendPollResponse,
|
||||
onEditPoll = onEditPoll,
|
||||
onEndPoll = ::onEndPoll,
|
||||
onLoadMore = ::onLoadMore,
|
||||
@@ -151,7 +151,7 @@ private fun PollHistoryList(
|
||||
pollHistoryItems: ImmutableList<PollHistoryItem>,
|
||||
hasMoreToLoad: Boolean,
|
||||
isLoading: Boolean,
|
||||
onSelectAnswer: (pollStartId: EventId, answerId: String) -> Unit,
|
||||
onSendPollResponse: (pollStartId: EventId, answerIds: List<String>) -> Unit,
|
||||
onEditPoll: (pollStartId: EventId) -> Unit,
|
||||
onEndPoll: (pollStartId: EventId) -> Unit,
|
||||
onLoadMore: () -> Unit,
|
||||
@@ -166,7 +166,7 @@ private fun PollHistoryList(
|
||||
items(pollHistoryItems) { pollHistoryItem ->
|
||||
PollHistoryItemRow(
|
||||
pollHistoryItem = pollHistoryItem,
|
||||
onSelectAnswer = onSelectAnswer,
|
||||
onSendPollResponse = onSendPollResponse,
|
||||
onEditPoll = onEditPoll,
|
||||
onEndPoll = onEndPoll,
|
||||
modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp)
|
||||
@@ -222,7 +222,7 @@ private fun LoadMoreButton(isLoading: Boolean, onClick: () -> Unit) {
|
||||
@Composable
|
||||
private fun PollHistoryItemRow(
|
||||
pollHistoryItem: PollHistoryItem,
|
||||
onSelectAnswer: (pollStartId: EventId, answerId: String) -> Unit,
|
||||
onSendPollResponse: (pollStartId: EventId, answerIds: List<String>) -> Unit,
|
||||
onEditPoll: (pollStartId: EventId) -> Unit,
|
||||
onEndPoll: (pollStartId: EventId) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -244,7 +244,7 @@ private fun PollHistoryItemRow(
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
PollContentView(
|
||||
state = pollHistoryItem.state,
|
||||
onSelectAnswer = onSelectAnswer,
|
||||
onSendPollResponse = onSendPollResponse,
|
||||
onEditPoll = onEditPoll,
|
||||
onEndPoll = onEndPoll,
|
||||
)
|
||||
|
||||
+8
-1
@@ -43,15 +43,21 @@ class DefaultPollContentStateFactory(
|
||||
?.value
|
||||
.orEmpty()
|
||||
}
|
||||
val currentSelectionCount = myVotes.size
|
||||
val maxSelections = content.maxSelections.toInt()
|
||||
val isSelectionLimitReached = currentSelectionCount >= maxSelections
|
||||
|
||||
val answerItems = content.answers.map { answer ->
|
||||
val answerVoteCount = content.votes[answer.id]?.size ?: 0
|
||||
val isSelected = answer.id in myVotes
|
||||
val isWinner = answer.id in winnerIds
|
||||
val percentage = if (totalVoteCount > 0) answerVoteCount.toFloat() / totalVoteCount.toFloat() else 0f
|
||||
// An answer is enabled if the poll hasn't ended and either the user can still select more answers or this answer is already selected
|
||||
val isEnabled = !isPollEnded && (!isSelectionLimitReached || isSelected)
|
||||
PollAnswerItem(
|
||||
answer = answer,
|
||||
isSelected = isSelected,
|
||||
isEnabled = !isPollEnded,
|
||||
isEnabled = isEnabled,
|
||||
isWinner = isWinner,
|
||||
showVotes = content.kind.isDisclosed || isPollEnded,
|
||||
votesCount = answerVoteCount,
|
||||
@@ -64,6 +70,7 @@ class DefaultPollContentStateFactory(
|
||||
question = content.question,
|
||||
answerItems = answerItems.toImmutableList(),
|
||||
pollKind = content.kind,
|
||||
maxSelections = content.maxSelections,
|
||||
isPollEditable = isEditable,
|
||||
isPollEnded = isPollEnded,
|
||||
isMine = isOwn,
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_create_poll_max_selections_headline">"Votes allowed per person"</string>
|
||||
</resources>
|
||||
+2
@@ -525,6 +525,8 @@ class CreatePollPresenterTest {
|
||||
assertThat(question).isEmpty()
|
||||
assertThat(answers).isEqualTo(listOf(Answer("", false), Answer("", false)))
|
||||
assertThat(pollKind).isEqualTo(PollKind.Disclosed)
|
||||
assertThat(maxSelections).isEqualTo(1)
|
||||
assertThat(maxAllowedSelections).isEqualTo(2)
|
||||
assertThat(showBackConfirmation).isFalse()
|
||||
assertThat(showDeleteConfirmation).isFalse()
|
||||
}
|
||||
|
||||
+1
@@ -24,6 +24,7 @@ class PollFormStateSaverTest {
|
||||
question = "question",
|
||||
answers = persistentListOf("answer1", "answer2"),
|
||||
isDisclosed = true,
|
||||
maxSelections = 2,
|
||||
)
|
||||
|
||||
val saved = with(CanSaveScope) {
|
||||
|
||||
+1
-1
@@ -117,7 +117,7 @@ class PollHistoryPresenterTest {
|
||||
state.eventSink(PollHistoryEvent.EndPoll(AN_EVENT_ID))
|
||||
runCurrent()
|
||||
endPollAction.verifyExecutionCount(1)
|
||||
state.eventSink(PollHistoryEvent.SelectPollAnswer(AN_EVENT_ID, "answer"))
|
||||
state.eventSink(PollHistoryEvent.SendPollResponse(AN_EVENT_ID, listOf("answer")))
|
||||
runCurrent()
|
||||
sendPollResponseAction.verifyExecutionCount(1)
|
||||
cancelAndConsumeRemainingEvents()
|
||||
|
||||
+1
-1
@@ -133,7 +133,7 @@ class PollHistoryViewTest : RobolectricTest() {
|
||||
useUnmergedTree = true,
|
||||
).performClick()
|
||||
eventsRecorder.assertSingle(
|
||||
PollHistoryEvent.SelectPollAnswer(eventId, answer.id)
|
||||
PollHistoryEvent.SendPollResponse(eventId, listOf(answer.id))
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+7
-6
@@ -59,10 +59,10 @@ class PollContentStateFactoryTest {
|
||||
)
|
||||
val expectedState = aPollContentState(
|
||||
answerItems = listOf(
|
||||
aPollAnswerItem(answer = A_POLL_ANSWER_1, votesCount = 3, percentage = 0.3f),
|
||||
aPollAnswerItem(answer = A_POLL_ANSWER_1, isEnabled = false, 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),
|
||||
aPollAnswerItem(answer = A_POLL_ANSWER_3, isEnabled = false),
|
||||
aPollAnswerItem(answer = A_POLL_ANSWER_4, isEnabled = false, votesCount = 1, percentage = 0.1f),
|
||||
)
|
||||
)
|
||||
assertThat(state).isEqualTo(expectedState)
|
||||
@@ -139,10 +139,10 @@ class PollContentStateFactoryTest {
|
||||
val expectedState = aPollContentState(
|
||||
pollKind = PollKind.Undisclosed,
|
||||
answerItems = listOf(
|
||||
aPollAnswerItem(answer = A_POLL_ANSWER_1, showVotes = false, votesCount = 3, percentage = 0.3f),
|
||||
aPollAnswerItem(answer = A_POLL_ANSWER_1, isEnabled = false, showVotes = false, votesCount = 3, percentage = 0.3f),
|
||||
aPollAnswerItem(answer = A_POLL_ANSWER_2, showVotes = false, isSelected = true, votesCount = 6, percentage = 0.6f),
|
||||
aPollAnswerItem(answer = A_POLL_ANSWER_3, showVotes = false),
|
||||
aPollAnswerItem(answer = A_POLL_ANSWER_4, showVotes = false, votesCount = 1, percentage = 0.1f),
|
||||
aPollAnswerItem(answer = A_POLL_ANSWER_3, isEnabled = false, showVotes = false),
|
||||
aPollAnswerItem(answer = A_POLL_ANSWER_4, isEnabled = false, showVotes = false, votesCount = 1, percentage = 0.1f),
|
||||
),
|
||||
)
|
||||
assertThat(state).isEqualTo(expectedState)
|
||||
@@ -241,6 +241,7 @@ class PollContentStateFactoryTest {
|
||||
question = question,
|
||||
answerItems = answerItems.toImmutableList(),
|
||||
pollKind = pollKind,
|
||||
maxSelections = 1u,
|
||||
isPollEditable = isEditable,
|
||||
isPollEnded = isEnded,
|
||||
isMine = isMine,
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ class FakeSendPollResponseAction : SendPollResponseAction {
|
||||
assert(executionCount == count)
|
||||
}
|
||||
|
||||
override suspend fun execute(timeline: Timeline, pollStartId: EventId, answerId: String): Result<Unit> {
|
||||
override suspend fun execute(timeline: Timeline, pollStartId: EventId, answerIds: List<String>): Result<Unit> {
|
||||
executionCount++
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
||||
+1
@@ -22,6 +22,7 @@ class FakePollContentStateFactory : PollContentStateFactory {
|
||||
question = content.question,
|
||||
answerItems = emptyList<PollAnswerItem>().toImmutableList(),
|
||||
pollKind = content.kind,
|
||||
maxSelections = content.maxSelections,
|
||||
isPollEditable = isEditable,
|
||||
isPollEnded = content.endTime != null,
|
||||
isMine = isOwn,
|
||||
|
||||
@@ -32,7 +32,7 @@ accompanist = "0.37.3"
|
||||
|
||||
# Test
|
||||
test_core = "1.7.0"
|
||||
roborazzi = "1.72.0"
|
||||
roborazzi = "1.73.0"
|
||||
|
||||
# Jetbrain
|
||||
datetime = "0.8.0"
|
||||
|
||||
+3
-2
@@ -19,7 +19,8 @@ import org.jsoup.safety.Safelist
|
||||
|
||||
/**
|
||||
* Converts the HTML string [FormattedBody.body] to a [Document] by parsing it.
|
||||
* If the message is not formatted or the format is not [MessageFormat.HTML] we return `null`.
|
||||
* If the message is not formatted, the format is not [MessageFormat.HTML], or nothing renderable is left once the
|
||||
* unsupported tags have been removed, we return `null` so that the plain text body is used instead.
|
||||
*
|
||||
* This will also make sure mentions are prefixed with `@`.
|
||||
*
|
||||
@@ -44,7 +45,7 @@ fun FormattedBody.toHtmlDocument(
|
||||
// Prepend `@` to mentions
|
||||
fixMentions(dom, permalinkParser)
|
||||
|
||||
dom
|
||||
dom.takeIf { it.text().isNotBlank() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+24
@@ -103,4 +103,28 @@ class ToHtmlDocumentTest : RobolectricTest() {
|
||||
})
|
||||
assertThat(document?.text()).isEqualTo("Hey Alice!")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toHtmlDocument - returns null if the body only contains unsupported tags`() {
|
||||
val body = FormattedBody(
|
||||
format = MessageFormat.HTML,
|
||||
body = "<img data-mx-emoticon src=\"mxc://matrix.org/anImage\" height=\"32\" width=\"32\" alt=\"\uD83D\uDE1C\" />"
|
||||
)
|
||||
|
||||
val document = body.toHtmlDocument(permalinkParser = FakePermalinkParser())
|
||||
|
||||
assertThat(document).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toHtmlDocument - returns a Document if some text survives next to unsupported tags`() {
|
||||
val body = FormattedBody(
|
||||
format = MessageFormat.HTML,
|
||||
body = "Look at this <img src=\"mxc://matrix.org/anImage\" alt=\"cat\" />!"
|
||||
)
|
||||
|
||||
val document = body.toHtmlDocument(permalinkParser = FakePermalinkParser())
|
||||
|
||||
assertThat(document?.text()).isEqualTo("Look at this !")
|
||||
}
|
||||
}
|
||||
|
||||
+12
@@ -149,4 +149,16 @@ class ToPlainTextTest : RobolectricTest() {
|
||||
)
|
||||
assertThat(messageType.toPlainText(permalinkParser = FakePermalinkParser())).isEqualTo("This is the message content.")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `TextMessageType toPlainText - returns the body if the formatted one only contains an image`() {
|
||||
val messageType = TextMessageType(
|
||||
body = "\uD83D\uDE1C",
|
||||
formatted = FormattedBody(
|
||||
format = MessageFormat.HTML,
|
||||
body = "<img data-mx-emoticon src=\"mxc://matrix.org/anImage\" height=\"32\" width=\"32\" alt=\"\uD83D\uDE1C\" />"
|
||||
)
|
||||
)
|
||||
assertThat(messageType.toPlainText(permalinkParser = FakePermalinkParser())).isEqualTo("\uD83D\uDE1C")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ private const val versionMonth = 9
|
||||
* Release number in the month. Value must be in [0,99].
|
||||
* Do not update this value. it is updated by the release script.
|
||||
*/
|
||||
private const val versionReleaseNumber = 0
|
||||
private const val versionReleaseNumber = 1
|
||||
|
||||
object Versions {
|
||||
/**
|
||||
|
||||
@@ -125,6 +125,7 @@ class KonsistPreviewTest {
|
||||
"PollContentViewCreatorPreview",
|
||||
"PollContentViewDisclosedPreview",
|
||||
"PollContentViewEndedPreview",
|
||||
"PollContentViewMultipleSelectionPreview",
|
||||
"PollContentViewUndisclosedPreview",
|
||||
"ProgressDialogWithContentPreview",
|
||||
"ProgressDialogWithTextAndContentPreview",
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -42,6 +42,31 @@ forbiddenTerms = {
|
||||
]
|
||||
}
|
||||
|
||||
# A complete, VALID placeholder, anchored to consume the whole token:
|
||||
# %% -> literal percent (allowed)
|
||||
# %s %d %f ... -> simple conversion
|
||||
# %1$s %2$d ... -> positional
|
||||
# %.2f %1$.2f -> optional precision
|
||||
_VALID_PLACEHOLDER = re.compile(
|
||||
r'%(?:\d+\$)?(?:\.\d+)?[a-zA-Z]'
|
||||
)
|
||||
|
||||
# Grab each candidate token: a '%' plus the run of chars that could belong to a
|
||||
# specifier (digits, '$', '.', and letters). This makes '%sd' and '%1d' surface
|
||||
# as single tokens instead of being partially swallowed.
|
||||
_PLACEHOLDER_MATCHES = re.compile(r'%%|%[0-9$.A-Za-z]*')
|
||||
|
||||
def find_invalid_placeholders(value):
|
||||
"""Return the list of malformed placeholder tokens found in `value`."""
|
||||
invalid = []
|
||||
for token in _PLACEHOLDER_MATCHES.findall(value):
|
||||
if token == '%%':
|
||||
continue # escaped percent is fine
|
||||
if not _VALID_PLACEHOLDER.fullmatch(token):
|
||||
invalid.append(token)
|
||||
return invalid
|
||||
|
||||
|
||||
content = minidom.parse(file)
|
||||
|
||||
errors = []
|
||||
@@ -60,6 +85,9 @@ for elem in content.getElementsByTagName('string'):
|
||||
matches = re.search(term, value)
|
||||
if matches and name not in exceptions:
|
||||
errors.append('Forbidden term "' + term + '" in string: "' + name + '": ' + value)
|
||||
invalid_placeholders = find_invalid_placeholders(value)
|
||||
for placeholder in invalid_placeholders:
|
||||
errors.append('Invalid placeholder "' + placeholder + '" in string: "' + name + '": ' + value)
|
||||
|
||||
### Plurals
|
||||
for elem in content.getElementsByTagName('plurals'):
|
||||
@@ -78,6 +106,9 @@ for elem in content.getElementsByTagName('plurals'):
|
||||
matches = re.search(term, value)
|
||||
if matches and name not in exceptions:
|
||||
errors.append('Forbidden term "' + term + '" in plural: "' + name + '": ' + value)
|
||||
invalid_placeholders = find_invalid_placeholders(value)
|
||||
for placeholder in invalid_placeholders:
|
||||
errors.append('Invalid placeholder "' + placeholder + '" in plural: "' + name + '": ' + value)
|
||||
|
||||
# If errors is not empty print the report
|
||||
if errors:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package: name='io.element.android.x' versionCode='202609000' versionName='26.09.0' platformBuildVersionName='17' platformBuildVersionCode='37' compileSdkVersion='37' compileSdkVersionCodename='17'
|
||||
package: name='io.element.android.x' versionCode='202609010' versionName='26.09.1' platformBuildVersionName='17' platformBuildVersionCode='37' compileSdkVersion='37' compileSdkVersionCodename='17'
|
||||
sdkVersion:'24'
|
||||
targetSdkVersion:'37'
|
||||
uses-permission: name='android.permission.POST_NOTIFICATIONS'
|
||||
|
||||
Reference in New Issue
Block a user