Merge pull request #2659 from element-hq/feature/bma/moreTest

Remove some dead code and add tests on RetrySendMessageMenu
This commit is contained in:
Benoit Marty
2024-04-05 09:47:03 +02:00
committed by GitHub
12 changed files with 130 additions and 316 deletions

View File

@@ -1,98 +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.messages.impl.timeline.components.event
import androidx.compose.material3.LocalTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.ui.strings.CommonStrings
import kotlin.math.ceil
// Allow to not overlap the timestamp with the text, in the message bubble.
// Compute the size of the worst case.
data class ExtraPadding(val extraWidth: Dp)
val noExtraPadding = ExtraPadding(0.dp)
/**
* See [io.element.android.features.messages.impl.timeline.components.TimelineEventTimestampView] for the related View.
* And https://www.figma.com/file/0MMNu7cTOzLOlWb7ctTkv3/Element-X?node-id=1819%253A99506 for the design.
*/
@Composable
fun TimelineItem.Event.toExtraPadding(): ExtraPadding {
val formattedTime = sentTime
val hasMessageSendingFailed = localSendState is LocalEventSendState.SendingFailed
val isMessageEdited = (content as? TimelineItemTextBasedContent)?.isEdited.orFalse()
val textMeasurer = rememberTextMeasurer(cacheSize = 128)
val density = LocalDensity.current
var strLen = 2.dp // Extra space char
if (isMessageEdited) {
val editedText = stringResource(id = CommonStrings.common_edited_suffix)
val extraLen = remember(editedText, density) { textMeasurer.getExtraPadding(editedText, density) } + 10.dp // Text + spacing
strLen += extraLen
}
strLen += remember(formattedTime, density) { textMeasurer.getExtraPadding(formattedTime, density) }
if (hasMessageSendingFailed) {
strLen += 19.dp // Image + spacing
// I do not know why, but adding extra widths avoid overlapping when the
// message is edited and in error.
if (isMessageEdited) {
strLen += 2.dp
}
}
return ExtraPadding(strLen)
}
private fun TextMeasurer.getExtraPadding(text: String, density: Density): Dp {
val timestampTextStyle = ElementTheme.typography.fontBodyXsRegular
val textWidth = measure(text = text, style = timestampTextStyle).size.width
return (textWidth / density.density).dp
}
/**
* Get a string to add to the content of the message to avoid overlapping the timestamp.
*/
@Composable
fun ExtraPadding.getStr(textStyle: TextStyle = LocalTextStyle.current): String {
if (extraWidth == 0.dp) return ""
val density = LocalDensity.current
val textMeasurer = rememberTextMeasurer(128)
val charWidth = remember(textStyle) { textMeasurer.measure(text = "\u00A0", style = textStyle).size.width }
val widthPx = remember(density, extraWidth) { with(density) { extraWidth.toPx() } }
// A space and some unbreakable spaces, always rounding the result to the next value if not a integer
return " " + "\u00A0".repeat(ceil(widthPx / charWidth).toInt())
}
@Composable
fun ExtraPadding.getDpSize(): Dp {
return extraWidth
}

View File

@@ -20,7 +20,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
sealed interface RetrySendMenuEvents {
data class EventSelected(val event: TimelineItem.Event) : RetrySendMenuEvents
data object RetrySend : RetrySendMenuEvents
data object RemoveFailed : RetrySendMenuEvents
data object Retry : RetrySendMenuEvents
data object Remove : RetrySendMenuEvents
data object Dismiss : RetrySendMenuEvents
}

View File

@@ -41,7 +41,7 @@ class RetrySendMenuPresenter @Inject constructor(
is RetrySendMenuEvents.EventSelected -> {
selectedEvent = event.event
}
RetrySendMenuEvents.RetrySend -> {
RetrySendMenuEvents.Retry -> {
coroutineScope.launch {
selectedEvent?.transactionId?.let { transactionId ->
room.retrySendMessage(transactionId)
@@ -49,7 +49,7 @@ class RetrySendMenuPresenter @Inject constructor(
selectedEvent = null
}
}
RetrySendMenuEvents.RemoveFailed -> {
RetrySendMenuEvents.Remove -> {
coroutineScope.launch {
selectedEvent?.transactionId?.let { transactionId ->
room.cancelSend(transactionId)

View File

@@ -17,7 +17,6 @@
package io.element.android.features.messages.impl.timeline.components.retrysendmenu
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
@@ -54,18 +53,18 @@ internal fun RetrySendMessageMenu(
}
fun onRetry() {
state.eventSink(RetrySendMenuEvents.RetrySend)
state.eventSink(RetrySendMenuEvents.Retry)
}
fun onRemoveFailed() {
state.eventSink(RetrySendMenuEvents.RemoveFailed)
fun onRemove() {
state.eventSink(RetrySendMenuEvents.Remove)
}
RetrySendMessageMenuBottomSheet(
modifier = modifier,
isVisible = isVisible,
onRetry = ::onRetry,
onRemoveFailed = ::onRemoveFailed,
onRemove = ::onRemove,
onDismiss = ::onDismiss
)
}
@@ -75,7 +74,7 @@ internal fun RetrySendMessageMenu(
private fun RetrySendMessageMenuBottomSheet(
isVisible: Boolean,
onRetry: () -> Unit,
onRemoveFailed: () -> Unit,
onRemove: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -95,7 +94,10 @@ private fun RetrySendMessageMenuBottomSheet(
}
}
) {
RetrySendMenuContents(onRetry = onRetry, onRemoveFailed = onRemoveFailed)
RetrySendMenuContents(
onRetry = onRetry,
onRemove = onRemove,
)
// FIXME remove after https://issuetracker.google.com/issues/275849044
Spacer(modifier = Modifier.height(32.dp))
}
@@ -106,7 +108,7 @@ private fun RetrySendMessageMenuBottomSheet(
@Composable
private fun ColumnScope.RetrySendMenuContents(
onRetry: () -> Unit,
onRemoveFailed: () -> Unit,
onRemove: () -> Unit,
sheetState: SheetState = rememberModalBottomSheetState(),
) {
val coroutineScope = rememberCoroutineScope()
@@ -142,22 +144,16 @@ private fun ColumnScope.RetrySendMenuContents(
modifier = Modifier.clickable {
coroutineScope.launch {
sheetState.hide()
onRemoveFailed()
onRemove()
}
}
)
}
@Suppress("UNUSED_PARAMETER")
@OptIn(ExperimentalMaterial3Api::class)
@PreviewsDayNight
@Composable
internal fun RetrySendMessageMenuPreview(@PreviewParameter(RetrySendMenuStateProvider::class) state: RetrySendMenuState) = ElementPreview {
// TODO restore RetrySendMessageMenuBottomSheet once the issue with bottom sheet not being previewable is fixed
Column {
RetrySendMenuContents(
onRetry = {},
onRemoveFailed = {},
)
}
RetrySendMessageMenu(
state = state,
)
}

View File

@@ -43,7 +43,6 @@ class RetrySendMenuPresenterTests {
val initialState = awaitItem()
val selectedEvent = aTimelineItemEvent()
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
assertThat(awaitItem().selectedEvent).isSameInstanceAs(selectedEvent)
}
}
@@ -57,8 +56,9 @@ class RetrySendMenuPresenterTests {
val selectedEvent = aTimelineItemEvent()
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
skipItems(1)
initialState.eventSink(RetrySendMenuEvents.Dismiss)
assertThat(room.cancelSendCount).isEqualTo(0)
assertThat(room.retrySendMessageCount).isEqualTo(0)
assertThat(awaitItem().selectedEvent).isNull()
}
}
@@ -72,8 +72,8 @@ class RetrySendMenuPresenterTests {
val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID)
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
skipItems(1)
initialState.eventSink(RetrySendMenuEvents.RetrySend)
initialState.eventSink(RetrySendMenuEvents.Retry)
assertThat(room.cancelSendCount).isEqualTo(0)
assertThat(room.retrySendMessageCount).isEqualTo(1)
assertThat(awaitItem().selectedEvent).isNull()
}
@@ -88,8 +88,8 @@ class RetrySendMenuPresenterTests {
val selectedEvent = aTimelineItemEvent(transactionId = null)
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
skipItems(1)
initialState.eventSink(RetrySendMenuEvents.RetrySend)
initialState.eventSink(RetrySendMenuEvents.Retry)
assertThat(room.cancelSendCount).isEqualTo(0)
assertThat(room.retrySendMessageCount).isEqualTo(0)
assertThat(awaitItem().selectedEvent).isNull()
}
@@ -105,8 +105,8 @@ class RetrySendMenuPresenterTests {
val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID)
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
skipItems(1)
initialState.eventSink(RetrySendMenuEvents.RetrySend)
initialState.eventSink(RetrySendMenuEvents.Retry)
assertThat(room.cancelSendCount).isEqualTo(0)
assertThat(room.retrySendMessageCount).isEqualTo(1)
assertThat(awaitItem().selectedEvent).isNull()
}
@@ -121,9 +121,9 @@ class RetrySendMenuPresenterTests {
val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID)
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
skipItems(1)
initialState.eventSink(RetrySendMenuEvents.RemoveFailed)
initialState.eventSink(RetrySendMenuEvents.Remove)
assertThat(room.cancelSendCount).isEqualTo(1)
assertThat(room.retrySendMessageCount).isEqualTo(0)
assertThat(awaitItem().selectedEvent).isNull()
}
}
@@ -137,9 +137,9 @@ class RetrySendMenuPresenterTests {
val selectedEvent = aTimelineItemEvent(transactionId = null)
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
skipItems(1)
initialState.eventSink(RetrySendMenuEvents.RemoveFailed)
initialState.eventSink(RetrySendMenuEvents.Remove)
assertThat(room.cancelSendCount).isEqualTo(0)
assertThat(room.retrySendMessageCount).isEqualTo(0)
assertThat(awaitItem().selectedEvent).isNull()
}
}
@@ -154,9 +154,9 @@ class RetrySendMenuPresenterTests {
val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID)
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
skipItems(1)
initialState.eventSink(RetrySendMenuEvents.RemoveFailed)
initialState.eventSink(RetrySendMenuEvents.Remove)
assertThat(room.cancelSendCount).isEqualTo(1)
assertThat(room.retrySendMessageCount).isEqualTo(0)
assertThat(awaitItem().selectedEvent).isNull()
}
}

View File

@@ -0,0 +1,90 @@
/*
* Copyright (c) 2024 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.impl.timeline.components.retrysendmenu
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.messages.impl.R
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.pressBackKey
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class)
class RetrySendMessageMenuTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `dismiss the bottom sheet emits the expected event`() {
val eventsRecorder = EventsRecorder<RetrySendMenuEvents>()
rule.setRetrySendMessageMenu(
aRetrySendMenuState(
event = aTimelineItemEvent(),
eventSink = eventsRecorder
),
)
rule.pressBackKey()
// Cannot test this for now.
// eventsRecorder.assertSingle(RetrySendMenuEvents.Dismiss)
}
@Config(qualifiers = "h1024dp")
@Test
fun `retry to send the event emits the expected event`() {
val eventsRecorder = EventsRecorder<RetrySendMenuEvents>()
rule.setRetrySendMessageMenu(
aRetrySendMenuState(
event = aTimelineItemEvent(),
eventSink = eventsRecorder
),
)
rule.clickOn(R.string.screen_room_retry_send_menu_send_again_action)
eventsRecorder.assertSingle(RetrySendMenuEvents.Retry)
}
@Config(qualifiers = "h1024dp")
@Test
fun `remove the event emits the expected event`() {
val eventsRecorder = EventsRecorder<RetrySendMenuEvents>()
rule.setRetrySendMessageMenu(
aRetrySendMenuState(
event = aTimelineItemEvent(),
eventSink = eventsRecorder
),
)
rule.clickOn(CommonStrings.action_remove)
eventsRecorder.assertSingle(RetrySendMenuEvents.Remove)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRetrySendMessageMenu(
state: RetrySendMenuState,
) {
setContent {
RetrySendMessageMenu(
state = state,
)
}
}

View File

@@ -1,107 +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.libraries.designsystem.modifiers
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.updateTransition
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.platform.debugInspectorInfo
import kotlin.math.sqrt
// Note: these modifiers come from https://gist.github.com/darvld/eb3844474baf2f3fc6d3ab44a4b4b5f8
/**
* A modifier that clips the composable content using an animated circle. The circle will
* expand/shrink with an animation whenever [visible] changes.
*
* For more fine-grained control over the transition, see this method's overload, which allows passing
* a [State] object to control the progress of the reveal animation.
*
* By default, the circle is centered in the content, but custom positions may be specified using
* [revealFrom]. Specified offsets should be between 0 (left/top) and 1 (right/bottom).*/
fun Modifier.circularReveal(
visible: Boolean,
showScrim: Boolean = false,
revealFrom: Offset = Offset(0.5f, 0.5f),
): Modifier = composed(
factory = {
val factor = updateTransition(visible, label = "Visibility")
.animateFloat(label = "revealFactor") { if (it) 1f else 0f }
circularReveal(factor, showScrim, revealFrom)
},
inspectorInfo = debugInspectorInfo {
name = "circularReveal"
properties["visible"] = visible
properties["revealFrom"] = revealFrom
}
)
/**
* A modifier that clips the composable content using a circular shape. The radius of the circle
* will be determined by the [transitionProgress].
*
* The values of the progress should be between 0 and 1.
*
* By default, the circle is centered in the content, but custom positions may be specified using
* [revealFrom]. Specified offsets should be between 0 (left/top) and 1 (right/bottom).
* */
fun Modifier.circularReveal(
transitionProgress: State<Float>,
showScrim: Boolean = false,
revealFrom: Offset = Offset(0.5f, 0.5f)
): Modifier {
return drawWithCache {
val path = Path()
val center = revealFrom.mapTo(size)
val radius = calculateRadius(revealFrom, size)
val scrimColor = if (showScrim) {
Color.Gray
} else {
Color.Transparent
}
path.addOval(Rect(center, radius * transitionProgress.value))
onDrawWithContent {
if (showScrim) {
drawRect(scrimColor, alpha = transitionProgress.value * 0.75f)
}
clipPath(path) { this@onDrawWithContent.drawContent() }
}
}
}
private fun Offset.mapTo(size: Size): Offset {
return Offset(x * size.width, y * size.height)
}
private fun calculateRadius(normalizedOrigin: Offset, size: Size) = with(normalizedOrigin) {
val x = (if (x > 0.5f) x else 1 - x) * size.width
val y = (if (y > 0.5f) y else 1 - y) * size.height
sqrt(x * x + y * y)
}

View File

@@ -1,67 +0,0 @@
/*
* Copyright (c) 2022 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.push.impl.permission
import android.Manifest
import android.app.Activity
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.core.content.ContextCompat
import io.element.android.libraries.di.ApplicationContext
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
import javax.inject.Inject
// TODO EAx move
class NotificationPermissionManager @Inject constructor(
private val sdkIntProvider: BuildVersionSdkIntProvider,
@ApplicationContext private val context: Context,
) {
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
fun isPermissionGranted(): Boolean {
return ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
}
/*
fun eventuallyRequestPermission(
activity: Activity,
requestPermissionLauncher: ActivityResultLauncher<Array<String>>,
showRationale: Boolean = true,
ignorePreference: Boolean = false,
) {
if (!sdkIntProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) return
// if (!vectorPreferences.areNotificationEnabledForDevice() && !ignorePreference) return
checkPermissions(
listOf(Manifest.permission.POST_NOTIFICATIONS),
activity,
activityResultLauncher = requestPermissionLauncher,
if (showRationale) R.string.permissions_rationale_msg_notification else 0
)
}
*/
fun eventuallyRevokePermission(
activity: Activity,
) {
if (!sdkIntProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) return
activity.revokeSelfPermissionOnKill(Manifest.permission.POST_NOTIFICATIONS)
}
}