Merge branch 'develop' into feature/bma/scroll-to-unread-messages

This commit is contained in:
Benoit Marty
2026-06-08 17:30:11 +02:00
278 changed files with 2880 additions and 1792 deletions
@@ -11,6 +11,9 @@ package io.element.android.features.messages.impl
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.lifecycle.subscribe
@@ -29,6 +32,7 @@ import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.features.forward.api.ForwardEntryPoint
import io.element.android.features.knockrequests.api.list.KnockRequestsListEntryPoint
import io.element.android.features.location.api.LocationService
import io.element.android.features.location.api.RenderingMapsNotSupportedDialog
import io.element.android.features.location.api.ShareLocationEntryPoint
import io.element.android.features.location.api.ShowLocationEntryPoint
import io.element.android.features.location.api.ShowLocationMode
@@ -53,6 +57,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.duration
import io.element.android.features.poll.api.create.CreatePollEntryPoint
import io.element.android.features.poll.api.create.CreatePollMode
import io.element.android.libraries.androidutils.system.DeviceHasVulkanSupport
import io.element.android.libraries.architecture.BackstackWithOverlayBox
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.callback
@@ -121,6 +126,7 @@ class MessagesFlowNode(
private val knockRequestsListEntryPoint: KnockRequestsListEntryPoint,
private val dateFormatter: DateFormatter,
private val coroutineDispatchers: CoroutineDispatchers,
private val hasVulkanSupport: DeviceHasVulkanSupport,
) : BaseFlowNode<MessagesFlowNode.NavTarget>(
backstack = BackStack(
initialElement = plugins.filterIsInstance<MessagesEntryPoint.Params>().first().initialTarget.toNavTarget(),
@@ -188,6 +194,8 @@ class MessagesFlowNode(
private val callback: MessagesEntryPoint.Callback = callback()
private var displayVulkanNotSupportedError by mutableStateOf(false)
override fun onBuilt() {
super.onBuilt()
lifecycle.subscribe(
@@ -267,7 +275,11 @@ class MessagesFlowNode(
}
override fun navigateToSendLocation() {
backstack.push(NavTarget.SendLocation(Timeline.Mode.Live))
if (hasVulkanSupport()) {
backstack.push(NavTarget.SendLocation(Timeline.Mode.Live))
} else {
displayVulkanNotSupportedError = true
}
}
override fun navigateToCreatePoll() {
@@ -279,7 +291,11 @@ class MessagesFlowNode(
}
override fun navigateToCurrentLiveLocation() {
backstack.push(NavTarget.LocationViewer(ShowLocationMode.Live(senderId = sessionId)))
if (hasVulkanSupport()) {
backstack.push(NavTarget.LocationViewer(ShowLocationMode.Live(senderId = sessionId)))
} else {
displayVulkanNotSupportedError = true
}
}
override fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean) {
@@ -506,7 +522,11 @@ class MessagesFlowNode(
}
override fun navigateToSendLocation() {
backstack.push(NavTarget.SendLocation(Timeline.Mode.Thread(navTarget.threadRootId)))
if (hasVulkanSupport()) {
backstack.push(NavTarget.SendLocation(Timeline.Mode.Thread(navTarget.threadRootId)))
} else {
displayVulkanNotSupportedError = true
}
}
override fun navigateToCreatePoll() {
@@ -518,7 +538,11 @@ class MessagesFlowNode(
}
override fun navigateToCurrentLiveLocation() {
backstack.push(NavTarget.LocationViewer(ShowLocationMode.Live(senderId = sessionId)))
if (hasVulkanSupport()) {
backstack.push(NavTarget.LocationViewer(ShowLocationMode.Live(senderId = sessionId)))
} else {
displayVulkanNotSupportedError = true
}
}
override fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean) {
@@ -607,18 +631,23 @@ class MessagesFlowNode(
)
}
is TimelineItemLocationContent -> {
val mode = when (event.content.mode) {
is TimelineItemLocationContent.Mode.Live -> ShowLocationMode.Live(event.senderId)
is TimelineItemLocationContent.Mode.Static -> ShowLocationMode.Static(
location = event.content.mode.location,
senderName = event.safeSenderName,
senderId = event.senderId,
senderAvatarUrl = event.senderAvatar.url,
timestamp = event.sentTimeMillis,
assetType = event.content.assetType,
)
if (hasVulkanSupport()) {
val mode = when (event.content.mode) {
is TimelineItemLocationContent.Mode.Live -> ShowLocationMode.Live(event.senderId)
is TimelineItemLocationContent.Mode.Static -> ShowLocationMode.Static(
location = event.content.mode.location,
senderName = event.safeSenderName,
senderId = event.senderId,
senderAvatarUrl = event.senderAvatar.url,
timestamp = event.sentTimeMillis,
assetType = event.content.assetType,
)
}
NavTarget.LocationViewer(mode = mode).takeIf { locationService.isServiceAvailable() }
} else {
displayVulkanNotSupportedError = true
null
}
NavTarget.LocationViewer(mode = mode).takeIf { locationService.isServiceAvailable() }
}
else -> null
}
@@ -691,6 +720,11 @@ class MessagesFlowNode(
@Composable
override fun View(modifier: Modifier) {
mentionSpanTheme.updateStyles()
if (displayVulkanNotSupportedError) {
RenderingMapsNotSupportedDialog { displayVulkanNotSupportedError = false }
}
CompositionLocalProvider(
LocalMentionSpanUpdater provides mentionSpanUpdater
) {
@@ -17,6 +17,8 @@ sealed interface AttachmentsPreviewEvent {
data object OpenImageEditor : AttachmentsPreviewEvent
data object CloseImageEditor : AttachmentsPreviewEvent
data object RotateImageToTheLeft : AttachmentsPreviewEvent
data object FlipImageHorizontally : AttachmentsPreviewEvent
data object FlipImageVertically : AttachmentsPreviewEvent
data object ApplyImageEdits : AttachmentsPreviewEvent
data object ResetImageEdits : AttachmentsPreviewEvent
data class UpdateImageCropRect(val cropRect: NormalizedCropRect) : AttachmentsPreviewEvent
@@ -295,6 +295,18 @@ class AttachmentsPreviewPresenter(
edits = pendingState.edits.rotateAntiClockwise()
)
}
AttachmentsPreviewEvent.FlipImageHorizontally -> {
val pendingState = imageEditorState ?: return
imageEditorState = pendingState.copy(
edits = pendingState.edits.flipHorizontally()
)
}
AttachmentsPreviewEvent.FlipImageVertically -> {
val pendingState = imageEditorState ?: return
imageEditorState = pendingState.copy(
edits = pendingState.edits.flipVertically()
)
}
AttachmentsPreviewEvent.ResetImageEdits -> {
imageEditorState = imageEditorState?.copy(
edits = AttachmentImageEdits()
@@ -37,6 +37,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.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.R
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
@@ -57,11 +58,12 @@ import io.element.android.libraries.designsystem.modifiers.niceClickable
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Switch
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.designsystem.utils.CommonDrawables
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
@@ -136,6 +138,8 @@ fun AttachmentsPreviewView(
state.eventSink(AttachmentsPreviewEvent.UpdateImageCropRect(cropRect))
},
onRotateClick = { state.eventSink(AttachmentsPreviewEvent.RotateImageToTheLeft) },
onFlipHorizontallyClick = { state.eventSink(AttachmentsPreviewEvent.FlipImageHorizontally) },
onFlipVerticallyClick = { state.eventSink(AttachmentsPreviewEvent.FlipImageVertically) },
onCancelClick = ::postCloseImageEditor,
onResetClick = ::postResetImageEditor,
onDoneClick = ::postApplyImageEdits,
@@ -161,10 +165,14 @@ fun AttachmentsPreviewView(
},
actions = {
if (state.canEditImage && canShowEditAction) {
TextButton(
stringResource(CommonStrings.action_edit),
onClick = ::postOpenImageEditor
)
IconButton(
onClick = ::postOpenImageEditor,
) {
Icon(
imageVector = CompoundIcons.Crop(),
contentDescription = stringResource(CommonStrings.action_edit),
)
}
}
}
)
@@ -87,30 +87,30 @@ class DefaultAttachmentImageEditor(
decodedBitmap.recycle()
}
val rotatedBitmap = normalizedBitmap.rotateQuarterTurns(edits.rotationQuarterTurns)
if (rotatedBitmap !== normalizedBitmap) {
val transformedBitmap = normalizedBitmap.applyEdits(edits)
if (transformedBitmap !== normalizedBitmap) {
normalizedBitmap.recycle()
}
val cropRect = edits.cropRect.toPixelRect(
imageWidth = rotatedBitmap.width,
imageHeight = rotatedBitmap.height,
imageWidth = transformedBitmap.width,
imageHeight = transformedBitmap.height,
)
val isCropUnchanged = cropRect.left == 0 && cropRect.top == 0 &&
cropRect.width() == rotatedBitmap.width && cropRect.height() == rotatedBitmap.height
cropRect.width() == transformedBitmap.width && cropRect.height() == transformedBitmap.height
val croppedBitmap = if (isCropUnchanged) {
rotatedBitmap
transformedBitmap
} else {
Bitmap.createBitmap(
rotatedBitmap,
transformedBitmap,
cropRect.left,
cropRect.top,
cropRect.width(),
cropRect.height(),
)
}
if (croppedBitmap !== rotatedBitmap) {
rotatedBitmap.recycle()
if (croppedBitmap !== transformedBitmap) {
transformedBitmap.recycle()
}
val editedMediaDir = File(context.cacheDir, EDITED_MEDIA_DIR_NAME).apply { mkdirs() }
@@ -141,11 +141,22 @@ internal fun exportedMimeTypeFor(sourceMimeType: String?): String {
}
}
private fun Bitmap.rotateQuarterTurns(quarterTurns: Int): Bitmap {
val normalizedTurns = (quarterTurns % 4 + 4) % 4
if (normalizedTurns == 0) return this
private fun Bitmap.applyEdits(edits: AttachmentImageEdits): Bitmap {
val normalizedTurns = (edits.rotationQuarterTurns % 4 + 4) % 4
if (normalizedTurns == 0 && !edits.isFlippedHorizontally && !edits.isFlippedVertically) {
return this
}
val centerX = width / 2f
val centerY = height / 2f
val matrix = Matrix().apply {
postRotate(normalizedTurns * 90f)
val scaleX = if (edits.isFlippedHorizontally) -1f else 1f
val scaleY = if (edits.isFlippedVertically) -1f else 1f
if (scaleX < 0f || scaleY < 0f) {
postScale(scaleX, scaleY, centerX, centerY)
}
if (normalizedTurns != 0) {
postRotate(normalizedTurns * 90f, centerX, centerY)
}
}
return Bitmap.createBitmap(this, 0, 0, width, height, matrix, true)
}
@@ -26,6 +26,8 @@ data class AttachmentImageEditorState(
data class AttachmentImageEdits(
val cropRect: NormalizedCropRect = NormalizedCropRect.default(),
val rotationQuarterTurns: Int = 0,
val isFlippedHorizontally: Boolean = false,
val isFlippedVertically: Boolean = false,
) {
val normalizedRotationQuarterTurns: Int
get() = rotationQuarterTurns % 4
@@ -34,18 +36,32 @@ data class AttachmentImageEdits(
get() = normalizedRotationQuarterTurns * 90
val hasChanges: Boolean
get() = cropRect != NormalizedCropRect.default() || normalizedRotationQuarterTurns != 0
get() = cropRect != NormalizedCropRect.default() ||
normalizedRotationQuarterTurns != 0 ||
isFlippedHorizontally ||
isFlippedVertically
fun rotateAntiClockwise(): AttachmentImageEdits {
return copy(
rotationQuarterTurns = (normalizedRotationQuarterTurns + 3) % 4,
// Also update the crop rect to keep the same selected area
cropRect = NormalizedCropRect(
left = cropRect.top,
top = 1f - cropRect.right,
right = cropRect.bottom,
bottom = 1f - cropRect.left,
)
cropRect = cropRect.rotateAntiClockwise()
)
}
fun flipHorizontally(): AttachmentImageEdits {
return copy(
isFlippedHorizontally = !isFlippedHorizontally,
// Also update the crop rect to keep the same selected area
cropRect = cropRect.flipHorizontally(),
)
}
fun flipVertically(): AttachmentImageEdits {
return copy(
isFlippedVertically = !isFlippedVertically,
// Also update the crop rect to keep the same selected area
cropRect = cropRect.flipVertically(),
)
}
}
@@ -135,6 +151,23 @@ data class NormalizedCropRect(
)
}
fun rotateAntiClockwise() = copy(
left = top,
top = 1f - right,
right = bottom,
bottom = 1f - left,
)
fun flipHorizontally() = copy(
left = 1f - right,
right = 1f - left,
)
fun flipVertically() = copy(
top = 1f - bottom,
bottom = 1f - top,
)
companion object {
fun default() = NormalizedCropRect(
left = DEFAULT_CROP_MARGIN,
@@ -75,6 +75,16 @@ open class AttachmentImageEditorStateProvider : PreviewParameterProvider<Attachm
),
previewDebug = true,
),
anAttachmentImageEditorState(
edits = AttachmentImageEdits(
cropRect = caterpillarCrop,
).flipHorizontally(),
),
anAttachmentImageEditorState(
edits = AttachmentImageEdits(
cropRect = caterpillarCrop,
).flipVertically(),
),
)
}
@@ -10,7 +10,6 @@ package io.element.android.features.messages.impl.attachments.preview.imageedito
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -24,7 +23,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -90,6 +88,8 @@ fun AttachmentImageEditorView(
state: AttachmentImageEditorState,
onCropRectChange: (NormalizedCropRect) -> Unit,
onRotateClick: () -> Unit,
onFlipHorizontallyClick: () -> Unit,
onFlipVerticallyClick: () -> Unit,
onResetClick: () -> Unit,
onCancelClick: () -> Unit,
onDoneClick: () -> Unit,
@@ -101,8 +101,18 @@ fun AttachmentImageEditorView(
state.edits.rotationDegrees,
state.edits.rotationDegrees,
)
val rotateButtonBackground = ElementTheme.colors.bgCanvasDefault
val flipHorizontalLabel = stringResource(R.string.screen_image_edition_a11y_flip_image_horizontally)
val flipHorizontalState = if (state.edits.isFlippedHorizontally) {
stringResource(R.string.screen_image_edition_a11y_flip_image_horizontally_state_flipped)
} else {
stringResource(R.string.screen_image_edition_a11y_flip_image_horizontally_state_original)
}
val flipVerticalLabel = stringResource(R.string.screen_image_edition_a11y_flip_image_vertically)
val flipVerticalState = if (state.edits.isFlippedVertically) {
stringResource(R.string.screen_image_edition_a11y_flip_image_vertically_state_flipped)
} else {
stringResource(R.string.screen_image_edition_a11y_flip_image_vertically_state_original)
}
Scaffold(
modifier = modifier.fillMaxSize(),
topBar = {
@@ -158,37 +168,57 @@ fun AttachmentImageEditorView(
onClick = onResetClick,
)
}
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.Center,
Row(
modifier = Modifier.weight(2f),
// Center the content horizontally
horizontalArrangement = Arrangement.Center,
) {
IconButton(
onClick = onFlipHorizontallyClick,
modifier = Modifier
.clearAndSetSemantics {
contentDescription = flipHorizontalLabel
stateDescription = flipHorizontalState
}
) {
Icon(
imageVector = CompoundIcons.FlipHorizontal(),
contentDescription = null,
)
}
IconButton(
onClick = onRotateClick,
modifier = Modifier
.background(
color = rotateButtonBackground,
shape = CircleShape,
)
.border(1.dp, ElementTheme.colors.borderInteractiveSecondary, CircleShape)
.clearAndSetSemantics {
contentDescription = rotateContentDescription
stateDescription = rotationStateDescription
}
) {
Icon(
modifier = Modifier
.size(22.dp),
imageVector = CompoundIcons.RotateLeft(),
contentDescription = null,
)
}
IconButton(
onClick = onFlipVerticallyClick,
modifier = Modifier
.clearAndSetSemantics {
contentDescription = flipVerticalLabel
stateDescription = flipVerticalState
}
) {
Icon(
imageVector = CompoundIcons.FlipVertical(),
contentDescription = null,
)
}
}
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.CenterEnd,
) {
TextButton(
text = stringResource(CommonStrings.action_done),
text = stringResource(CommonStrings.action_save),
onClick = onDoneClick,
)
}
@@ -204,6 +234,8 @@ private fun BoxScope.CropEditorCanvas(
) {
var imageSize by remember(state.localMedia.uri) { mutableStateOf(IntSize.Zero) }
val rotationQuarterTurns = state.edits.normalizedRotationQuarterTurns
val flipScaleX = if (state.edits.isFlippedHorizontally) -1f else 1f
val flipScaleY = if (state.edits.isFlippedVertically) -1f else 1f
var imageRect by remember { mutableStateOf(Rect.Zero) }
@@ -257,6 +289,10 @@ private fun BoxScope.CropEditorCanvas(
contentDescription = null,
modifier = Modifier
.requiredSize(imageLayoutWidthDp, imageLayoutHeightDp)
.graphicsLayer {
scaleX = flipScaleX
scaleY = flipScaleY
}
.graphicsLayer { rotationZ = rotationQuarterTurns * 90f },
contentScale = ContentScale.Fit,
)
@@ -266,6 +302,10 @@ private fun BoxScope.CropEditorCanvas(
contentDescription = stringResource(CommonStrings.common_image),
modifier = Modifier
.requiredSize(imageLayoutWidthDp, imageLayoutHeightDp)
.graphicsLayer {
scaleX = flipScaleX
scaleY = flipScaleY
}
.graphicsLayer { rotationZ = rotationQuarterTurns * 90f },
contentScale = ContentScale.Fit,
onState = { painterState ->
@@ -642,6 +682,8 @@ internal fun AttachmentImageEditorViewPreview(
state = state,
onCropRectChange = {},
onRotateClick = {},
onFlipHorizontallyClick = {},
onFlipVerticallyClick = {},
onResetClick = {},
onCancelClick = {},
onDoneClick = {},
@@ -208,7 +208,8 @@ class PinnedMessagesListPresenter(
combine(timelineItemsFlow, room.membersStateFlow) { items, membersState ->
timelineItemsFactory.replaceWith(
timelineItems = items,
roomMembers = membersState.roomMembers().orEmpty()
roomMembers = membersState.roomMembers().orEmpty(),
renderReadReceipts = false,
)
}.launchIn(this)
@@ -217,7 +217,6 @@ private fun PinnedMessagesListLoaded(
timelineItem = timelineItem,
timelineMode = Timeline.Mode.PinnedEvents,
timelineRoomInfo = state.timelineRoomInfo,
renderReadReceipts = false,
timelineProtectionState = state.timelineProtectionState,
isLastOutgoingMessage = false,
focusedEventId = null,
@@ -146,9 +146,6 @@ class TimelinePresenter(
val suppressJumpToUnread = remember { mutableStateOf(false) }
val resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailurePresenter.present()
val renderReadReceipts by remember {
sessionPreferencesStore.isRenderReadReceiptsEnabled()
}.collectAsState(initial = true)
val isLive by remember {
timelineController.isLive()
}.collectAsState(initial = true)
@@ -156,9 +153,6 @@ class TimelinePresenter(
val displayThreadSummaries by produceState(false) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.Threads)
}
val displayFloatingDateBadge by produceState(false) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.FloatingDateBadge)
}
val displayJumpToUnread by produceState(false) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.JumpToUnread)
}
@@ -274,13 +268,18 @@ class TimelinePresenter(
}
.launchIn(this)
combine(timelineController.timelineItems(), room.membersStateFlow) { items, membersState ->
combine(
timelineController.timelineItems(),
room.membersStateFlow,
sessionPreferencesStore.isRenderReadReceiptsEnabled(),
) { items, membersState, renderReadReceipts ->
val parent = analyticsService.getLongRunningTransaction(DisplayFirstTimelineItems)
val transaction = parent?.startChild("timelineItemsFactory.replaceWith", "Processing timeline items")
transaction?.putExtraData(AnalyticsUserData.TIMELINE_ITEM_COUNT, items.count().toString())
timelineItemsFactory.replaceWith(
timelineItems = items,
roomMembers = membersState.roomMembers().orEmpty()
roomMembers = membersState.roomMembers().orEmpty(),
renderReadReceipts = renderReadReceipts,
)
transaction?.finish()
items
@@ -379,14 +378,12 @@ class TimelinePresenter(
timelineItems = timelineItems,
timelineMode = timelineMode,
timelineRoomInfo = timelineRoomInfo,
renderReadReceipts = renderReadReceipts,
newEventState = newEventState.value,
isLive = isLive,
focusRequestState = focusRequestState.value,
messageShieldDialogData = messageShieldDialogData.value,
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
displayThreadSummaries = displayThreadSummaries,
displayFloatingDateBadge = displayFloatingDateBadge,
displayJumpToUnread = displayJumpToUnread,
jumpToUnread = jumpToUnread.value,
eventSink = ::handleEvent,
@@ -26,7 +26,6 @@ data class TimelineState(
val timelineItems: ImmutableList<TimelineItem>,
val timelineRoomInfo: TimelineRoomInfo,
val timelineMode: Timeline.Mode,
val renderReadReceipts: Boolean,
val newEventState: NewEventState,
val isLive: Boolean,
val focusRequestState: FocusRequestState,
@@ -34,7 +33,6 @@ data class TimelineState(
val messageShieldDialogData: MessageShieldData?,
val resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState,
val displayThreadSummaries: Boolean,
val displayFloatingDateBadge: Boolean,
val displayJumpToUnread: Boolean,
val jumpToUnread: JumpToUnreadState,
val eventSink: (TimelineEvent) -> Unit,
@@ -51,14 +51,12 @@ import kotlin.random.Random
fun aTimelineState(
timelineItems: ImmutableList<TimelineItem> = persistentListOf(),
timelineMode: Timeline.Mode = Timeline.Mode.Live,
renderReadReceipts: Boolean = false,
timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(),
focusedEventIndex: Int = -1,
isLive: Boolean = true,
messageShield: MessageShield? = null,
resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState = aResolveVerifiedUserSendFailureState(),
displayThreadSummaries: Boolean = false,
displayFloatingDateBadge: Boolean = false,
displayJumpToUnread: Boolean = false,
jumpToUnread: JumpToUnreadState = JumpToUnreadState.Hidden,
newEventState: NewEventState = NewEventState.None,
@@ -74,14 +72,12 @@ fun aTimelineState(
timelineItems = timelineItems,
timelineMode = timelineMode,
timelineRoomInfo = timelineRoomInfo,
renderReadReceipts = renderReadReceipts,
newEventState = newEventState,
isLive = isLive,
focusRequestState = focusRequestState,
messageShieldDialogData = messageShield?.let { MessageShieldData(it) },
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
displayThreadSummaries = displayThreadSummaries,
displayFloatingDateBadge = displayFloatingDateBadge,
displayJumpToUnread = displayJumpToUnread,
jumpToUnread = jumpToUnread,
eventSink = eventSink,
@@ -207,7 +207,6 @@ fun TimelineView(
timelineMode = state.timelineMode,
timelineRoomInfo = state.timelineRoomInfo,
timelineProtectionState = timelineProtectionState,
renderReadReceipts = state.renderReadReceipts,
isLastOutgoingMessage = state.isLastOutgoingMessage(timelineItem.identifier()),
focusedEventId = state.focusedEventId,
displayThreadSummaries = state.displayThreadSummaries,
@@ -254,7 +253,7 @@ fun TimelineView(
onFocusOnEvent = ::onFocusOnEvent,
)
if (state.displayFloatingDateBadge && useReverseLayout) {
if (useReverseLayout) {
FloatingDateBadgeOverlay(
lazyListState = lazyListState,
timelineItems = state.timelineItems,
@@ -22,7 +22,6 @@ internal fun ATimelineItemEventRow(
event: TimelineItem.Event,
timelineMode: Timeline.Mode = Timeline.Mode.Live,
timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(),
renderReadReceipts: Boolean = false,
isLastOutgoingMessage: Boolean = false,
timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(),
displayThreadSummaries: Boolean = false,
@@ -30,7 +29,6 @@ internal fun ATimelineItemEventRow(
event = event,
timelineMode = timelineMode,
timelineRoomInfo = timelineRoomInfo,
renderReadReceipts = renderReadReceipts,
timelineProtectionState = timelineProtectionState,
isLastOutgoingMessage = isLastOutgoingMessage,
displayThreadSummaries = displayThreadSummaries,
@@ -52,7 +52,6 @@ internal fun TimelineItemCallNotifyView(
timelineRoomInfo: TimelineRoomInfo,
event: TimelineItem.Event,
content: TimelineItemRtcNotificationContent,
renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
onLongClick: (TimelineItem.Event) -> Unit,
onReadReceiptsClick: (TimelineItem.Event) -> Unit,
@@ -106,7 +105,6 @@ internal fun TimelineItemCallNotifyView(
isLastOutgoingMessage = isLastOutgoingMessage,
receipts = event.readReceiptState.receipts,
),
renderReadReceipts = renderReadReceipts,
onReadReceiptsClick = { onReadReceiptsClick(event) },
modifier = Modifier.padding(top = 4.dp),
)
@@ -146,8 +144,10 @@ private fun getIcon(
@PreviewsDayNight
@Composable
internal fun TimelineItemCallNotifyViewPreview() = ElementPreview {
val readReceiptState = aTimelineItemReadReceipts(
receipts = List(3) { aReadReceiptData(it) },
val readReceiptState = mutableListOf(
aTimelineItemReadReceipts(
receipts = List(3) { aReadReceiptData(it) },
)
)
Column(modifier = Modifier.padding(bottom = 16.dp)) {
listOf(false, true).forEach { isDm ->
@@ -162,13 +162,10 @@ internal fun TimelineItemCallNotifyViewPreview() = ElementPreview {
timelineRoomInfo = aTimelineRoomInfo(isDm = isDm),
event = aTimelineItemEvent(
content = content,
readReceiptState = readReceiptState,
// Only display read receipts for the first item
readReceiptState = readReceiptState.removeFirstOrNull() ?: aTimelineItemReadReceipts(),
),
content = content,
// Render read receipts for the first item only
renderReadReceipts = !isDm &&
callIntent == CallIntent.AUDIO &&
state == RtcNotificationState.Started,
isLastOutgoingMessage = false,
onLongClick = {},
onReadReceiptsClick = {},
@@ -142,7 +142,6 @@ fun TimelineItemEventRow(
timelineMode: Timeline.Mode,
timelineRoomInfo: TimelineRoomInfo,
timelineProtectionState: TimelineProtectionState,
renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
displayThreadSummaries: Boolean,
onEventClick: () -> Unit,
@@ -296,7 +295,6 @@ fun TimelineItemEventRow(
isLastOutgoingMessage = isLastOutgoingMessage,
receipts = event.readReceiptState.receipts,
),
renderReadReceipts = renderReadReceipts,
onReadReceiptsClick = { onReadReceiptClick(event) },
modifier = Modifier.padding(top = 4.dp)
)
@@ -37,7 +37,6 @@ internal fun TimelineItemEventRowWithRRPreview(
timelineItemReactions = aTimelineItemReactions(count = 0),
readReceiptState = TimelineItemReadReceipts(state.receipts),
),
renderReadReceipts = true,
isLastOutgoingMessage = false,
)
// A message from current user
@@ -49,7 +48,6 @@ internal fun TimelineItemEventRowWithRRPreview(
timelineItemReactions = aTimelineItemReactions(count = 0),
readReceiptState = TimelineItemReadReceipts(state.receipts),
),
renderReadReceipts = true,
isLastOutgoingMessage = false,
)
// Another message from current user
@@ -61,7 +59,6 @@ internal fun TimelineItemEventRowWithRRPreview(
timelineItemReactions = aTimelineItemReactions(count = 0),
readReceiptState = TimelineItemReadReceipts(state.receipts),
),
renderReadReceipts = true,
isLastOutgoingMessage = true,
)
}
@@ -43,7 +43,6 @@ fun TimelineItemGroupedEventsRow(
timelineMode: Timeline.Mode,
timelineRoomInfo: TimelineRoomInfo,
timelineProtectionState: TimelineProtectionState,
renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
focusedEventId: EventId?,
displayThreadSummaries: Boolean,
@@ -89,7 +88,6 @@ fun TimelineItemGroupedEventsRow(
timelineRoomInfo = timelineRoomInfo,
timelineProtectionState = timelineProtectionState,
focusedEventId = focusedEventId,
renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
displayThreadSummaries = displayThreadSummaries,
onClick = onClick,
@@ -117,7 +115,6 @@ private fun TimelineItemGroupedEventsRowContent(
timelineRoomInfo: TimelineRoomInfo,
timelineProtectionState: TimelineProtectionState,
focusedEventId: EventId?,
renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
displayThreadSummaries: Boolean,
onClick: (TimelineItem.Event) -> Unit,
@@ -173,7 +170,6 @@ private fun TimelineItemGroupedEventsRowContent(
timelineItem = subGroupEvent,
timelineRoomInfo = timelineRoomInfo,
timelineProtectionState = timelineProtectionState,
renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
focusedEventId = focusedEventId,
displayThreadSummaries = displayThreadSummaries,
@@ -193,14 +189,13 @@ private fun TimelineItemGroupedEventsRowContent(
)
}
}
} else if (renderReadReceipts) {
} else if (timelineItem.aggregatedReadReceipts.isNotEmpty()) {
TimelineItemReadReceiptView(
state = ReadReceiptViewState(
sendState = null,
isLastOutgoingMessage = false,
receipts = timelineItem.aggregatedReadReceipts,
),
renderReadReceipts = true,
onReadReceiptsClick = onExpandGroupClick
)
}
@@ -219,7 +214,6 @@ internal fun TimelineItemGroupedEventsRowContentExpandedPreview() = ElementPrevi
timelineRoomInfo = aTimelineRoomInfo(),
timelineProtectionState = aTimelineProtectionState(),
focusedEventId = events.events.first().eventId,
renderReadReceipts = true,
isLastOutgoingMessage = false,
displayThreadSummaries = false,
onClick = {},
@@ -247,7 +241,6 @@ internal fun TimelineItemGroupedEventsRowContentCollapsePreview() = ElementPrevi
timelineRoomInfo = aTimelineRoomInfo(),
timelineProtectionState = aTimelineProtectionState(),
focusedEventId = null,
renderReadReceipts = true,
isLastOutgoingMessage = false,
displayThreadSummaries = false,
onClick = {},
@@ -56,7 +56,6 @@ internal fun TimelineItemRow(
timelineItem: TimelineItem,
timelineMode: Timeline.Mode,
timelineRoomInfo: TimelineRoomInfo,
renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
timelineProtectionState: TimelineProtectionState,
focusedEventId: EventId?,
@@ -114,7 +113,6 @@ internal fun TimelineItemRow(
is TimelineItemStateContent, is TimelineItemLegacyCallInviteContent -> {
TimelineItemStateEventRow(
event = timelineItem,
renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
onClick = { onContentClick(timelineItem) },
onReadReceiptsClick = onReadReceiptClick,
@@ -127,7 +125,6 @@ internal fun TimelineItemRow(
timelineRoomInfo = timelineRoomInfo,
event = timelineItem,
content = timelineItem.content,
renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
onLongClick = onLongClick,
onReadReceiptsClick = onReadReceiptClick,
@@ -167,7 +164,6 @@ internal fun TimelineItemRow(
event = timelineItem,
timelineMode = timelineMode,
timelineRoomInfo = timelineRoomInfo,
renderReadReceipts = renderReadReceipts,
timelineProtectionState = timelineProtectionState,
isLastOutgoingMessage = isLastOutgoingMessage,
displayThreadSummaries = displayThreadSummaries,
@@ -196,7 +192,6 @@ internal fun TimelineItemRow(
timelineMode = timelineMode,
timelineRoomInfo = timelineRoomInfo,
timelineProtectionState = timelineProtectionState,
renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
focusedEventId = focusedEventId,
displayThreadSummaries = displayThreadSummaries,
@@ -39,7 +39,6 @@ import kotlinx.collections.immutable.persistentListOf
@Composable
fun TimelineItemStateEventRow(
event: TimelineItem.Event,
renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit,
@@ -86,7 +85,6 @@ fun TimelineItemStateEventRow(
isLastOutgoingMessage = isLastOutgoingMessage,
receipts = event.readReceiptState.receipts,
),
renderReadReceipts = renderReadReceipts,
onReadReceiptsClick = { onReadReceiptsClick(event) },
)
}
@@ -104,7 +102,6 @@ internal fun TimelineItemStateEventRowPreview() = ElementPreview {
receipts = persistentListOf(aReadReceiptData(0)),
)
),
renderReadReceipts = true,
isLastOutgoingMessage = false,
onClick = {},
onLongClick = {},
@@ -53,27 +53,24 @@ import kotlinx.collections.immutable.ImmutableList
@Composable
fun TimelineItemReadReceiptView(
state: ReadReceiptViewState,
renderReadReceipts: Boolean,
onReadReceiptsClick: () -> Unit,
modifier: Modifier = Modifier,
) {
if (state.receipts.isNotEmpty()) {
if (renderReadReceipts) {
ReadReceiptsRow(
modifier = modifier.clearAndSetSemantics {
hideFromAccessibility()
}
) {
ReadReceiptsAvatars(
receipts = state.receipts,
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.clickable {
onReadReceiptsClick()
}
.padding(2.dp)
)
ReadReceiptsRow(
modifier = modifier.clearAndSetSemantics {
hideFromAccessibility()
}
) {
ReadReceiptsAvatars(
receipts = state.receipts,
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.clickable {
onReadReceiptsClick()
}
.padding(2.dp)
)
}
} else {
when (state.sendState) {
@@ -209,7 +206,6 @@ internal fun TimelineItemReadReceiptViewPreview(
) = ElementPreview {
TimelineItemReadReceiptView(
state = state,
renderReadReceipts = true,
onReadReceiptsClick = {},
)
}
@@ -64,22 +64,24 @@ class TimelineItemsFactory(
suspend fun replaceWith(
timelineItems: List<MatrixTimelineItem>,
roomMembers: List<RoomMember>,
renderReadReceipts: Boolean,
) = withContext(dispatchers.computation) {
lock.withLock {
diffCacheUpdater.updateWith(timelineItems)
buildAndEmitTimelineItemStates(timelineItems, roomMembers)
buildAndEmitTimelineItemStates(timelineItems, roomMembers, renderReadReceipts)
}
}
private suspend fun buildAndEmitTimelineItemStates(
timelineItems: List<MatrixTimelineItem>,
roomMembers: List<RoomMember>,
renderReadReceipts: Boolean,
) {
val newTimelineItemStates = ArrayList<TimelineItem>()
for (index in diffCache.indices().reversed()) {
val cacheItem = diffCache.get(index)
if (cacheItem == null) {
buildAndCacheItem(timelineItems, index, roomMembers)?.also { timelineItemState ->
buildAndCacheItem(timelineItems, index, roomMembers, renderReadReceipts)?.also { timelineItemState ->
newTimelineItemStates.add(timelineItemState)
}
} else {
@@ -87,7 +89,8 @@ class TimelineItemsFactory(
eventItemFactory.update(
timelineItem = cacheItem,
receivedMatrixTimelineItem = timelineItems[index] as MatrixTimelineItem.Event,
roomMembers = roomMembers
roomMembers = roomMembers,
renderReadReceipts = renderReadReceipts,
)
} else {
cacheItem
@@ -103,10 +106,11 @@ class TimelineItemsFactory(
timelineItems: List<MatrixTimelineItem>,
index: Int,
roomMembers: List<RoomMember>,
renderReadReceipts: Boolean,
): TimelineItem? {
val timelineItem =
when (val currentTimelineItem = timelineItems[index]) {
is MatrixTimelineItem.Event -> eventItemFactory.create(currentTimelineItem, index, timelineItems, roomMembers)
is MatrixTimelineItem.Event -> eventItemFactory.create(currentTimelineItem, index, timelineItems, roomMembers, renderReadReceipts)
is MatrixTimelineItem.Virtual -> virtualItemFactory.create(currentTimelineItem)
MatrixTimelineItem.Other -> null
}
@@ -57,6 +57,7 @@ class TimelineItemEventFactory(
index: Int,
timelineItems: List<MatrixTimelineItem>,
roomMembers: List<RoomMember>,
renderReadReceipts: Boolean,
): TimelineItem.Event {
val currentSender = currentTimelineItem.event.sender
val groupPosition =
@@ -116,7 +117,7 @@ class TimelineItemEventFactory(
sentDate = sentDate,
groupPosition = groupPosition,
reactionsState = currentTimelineItem.computeReactionsState(),
readReceiptState = currentTimelineItem.computeReadReceiptState(roomMembers),
readReceiptState = currentTimelineItem.computeReadReceiptState(roomMembers, renderReadReceipts),
localSendState = currentTimelineItem.event.localSendState,
inReplyTo = currentTimelineItem.event.inReplyTo()?.map(permalinkParser = permalinkParser),
threadInfo = mappedThreadInfo,
@@ -133,9 +134,10 @@ class TimelineItemEventFactory(
timelineItem: TimelineItem.Event,
receivedMatrixTimelineItem: MatrixTimelineItem.Event,
roomMembers: List<RoomMember>,
renderReadReceipts: Boolean,
): TimelineItem.Event {
return timelineItem.copy(
readReceiptState = receivedMatrixTimelineItem.computeReadReceiptState(roomMembers)
readReceiptState = receivedMatrixTimelineItem.computeReadReceiptState(roomMembers, renderReadReceipts)
)
}
@@ -180,8 +182,9 @@ class TimelineItemEventFactory(
private fun MatrixTimelineItem.Event.computeReadReceiptState(
roomMembers: List<RoomMember>,
renderReadReceipts: Boolean,
): TimelineItemReadReceipts {
if (!config.computeReadReceipts) {
if (!config.computeReadReceipts || !renderReadReceipts) {
return TimelineItemReadReceipts(receipts = persistentListOf())
}
return TimelineItemReadReceipts(
@@ -16,6 +16,12 @@
<string name="emoji_picker_category_places">"Reisimine ja kohad"</string>
<string name="emoji_picker_category_recent">"Hiljutised emojid"</string>
<string name="emoji_picker_category_symbols">"Sümbolid"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally">"Pööra pilti ümber rõhttelje"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Pilt on pööratud ümber rõhttelje"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Pildi algne variant"</string>
<string name="screen_image_edition_a11y_flip_image_vertically">"Pööra pilti ümber püsttelje"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_flipped">"Pilt on pööratud ümber püsttelje"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_original">"Pildi algne variant"</string>
<string name="screen_image_edition_a11y_rotate_to_the_left">"Pööra pilti vasakule"</string>
<plurals name="screen_image_edition_a11y_rotation_state">
<item quantity="one">"%1$d kraad"</item>
@@ -16,10 +16,18 @@
<string name="emoji_picker_category_places">"Matkustaminen ja paikat"</string>
<string name="emoji_picker_category_recent">"Viimeaikaiset emojit"</string>
<string name="emoji_picker_category_symbols">"Symbolit"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally">"Käännä kuva vaakasuunnassa"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Käännetty vaakasuunnassa"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Alkuperäinen"</string>
<string name="screen_image_edition_a11y_flip_image_vertically">"Käännä kuva pystysuunnassa"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_flipped">"Käännetty pystysuunnassa"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_original">"Alkuperäinen"</string>
<string name="screen_image_edition_a11y_rotate_to_the_left">"Kierrä kuvaa vasemmalle"</string>
<plurals name="screen_image_edition_a11y_rotation_state">
<item quantity="one">"%1$d aste"</item>
<item quantity="other">"%1$d astetta"</item>
</plurals>
<string name="screen_image_edition_title">"Muokkaa kuvaa"</string>
<string name="screen_media_upload_preview_caption_warning">"Kuvatekstit eivät välttämättä näy ihmisille, jotka käyttävät vanhempia sovelluksia."</string>
<string name="screen_media_upload_preview_change_video_quality_prompt">"Napauta muuttaaksesi videon lähetyslaatua"</string>
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Tiedostoa ei voitu lähettää."</string>
@@ -30,6 +38,7 @@
<string name="screen_media_upload_preview_item_count">"Kohde %1$d / %2$d"</string>
<string name="screen_media_upload_preview_optimize_image_quality_title">"Optimoi kuvanlaatu"</string>
<string name="screen_media_upload_preview_processing">"Käsitellään…"</string>
<string name="screen_media_upload_preview_title">"Lisää mediaa"</string>
<string name="screen_report_content_block_user">"Estä käyttäjä"</string>
<string name="screen_report_content_block_user_hint">"Valitse tämä, jos haluat piilottaa kaikki nykyiset ja tulevat viestit tältä käyttäjältä"</string>
<string name="screen_report_content_explanation">"Tämä viesti ilmoitetaan kotipalvelimesi ylläpitäjälle. Ylläpitäjä ei pysty lukemaan salattuja viestejä."</string>
@@ -16,6 +16,18 @@
<string name="emoji_picker_category_places">"Utazás és helyek"</string>
<string name="emoji_picker_category_recent">"Legutóbbi emodzsik"</string>
<string name="emoji_picker_category_symbols">"Szimbólumok"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally">"Kép vízszintes tükrözése"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Vízszintesen tükrözve"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Eredeti"</string>
<string name="screen_image_edition_a11y_flip_image_vertically">"Kép függőleges tükrözése"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_flipped">"Függőlegesen tükrözve"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_original">"Eredeti"</string>
<string name="screen_image_edition_a11y_rotate_to_the_left">"Kép elforgatása balra"</string>
<plurals name="screen_image_edition_a11y_rotation_state">
<item quantity="one">"%1$d fok"</item>
<item quantity="other">"%1$d fok"</item>
</plurals>
<string name="screen_image_edition_title">"Fotó szerkesztése"</string>
<string name="screen_media_upload_preview_caption_warning">"Előfordulhat, hogy a feliratok nem láthatók a régebbi alkalmazásokat használók számára."</string>
<string name="screen_media_upload_preview_change_video_quality_prompt">"Koppintson a feltöltött videók minőségének módosításához"</string>
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"A fájl nem tölthető fel."</string>
@@ -26,6 +38,7 @@
<string name="screen_media_upload_preview_item_count">"%1$d. elem / %2$d"</string>
<string name="screen_media_upload_preview_optimize_image_quality_title">"Képminőség optimalizációja"</string>
<string name="screen_media_upload_preview_processing">"Feldolgozás…"</string>
<string name="screen_media_upload_preview_title">"Média hozzáadása"</string>
<string name="screen_report_content_block_user">"Felhasználó letiltása"</string>
<string name="screen_report_content_block_user_hint">"Jelölje be, ha el akarja rejteni az összes jelenlegi és jövőbeli üzenetet ettől a felhasználótól"</string>
<string name="screen_report_content_explanation">"Ez az üzenet jelentve lesz a Matrix-kiszolgáló adminisztrátorának. Nem fogja tudni elolvasni a titkosított üzeneteket."</string>
@@ -39,7 +39,7 @@
<string name="screen_room_attachment_source_camera">"カメラ"</string>
<string name="screen_room_attachment_source_camera_photo">"写真を撮影"</string>
<string name="screen_room_attachment_source_camera_video">"動画を撮影"</string>
<string name="screen_room_attachment_source_files">"添付ファイル"</string>
<string name="screen_room_attachment_source_files">"ファイルを添付"</string>
<string name="screen_room_attachment_source_gallery">"アルバムの写真と動画"</string>
<string name="screen_room_attachment_source_location">"場所を共有"</string>
<string name="screen_room_attachment_source_poll">"投票"</string>
@@ -16,6 +16,11 @@
<string name="emoji_picker_category_places">"여행 &amp; 장소"</string>
<string name="emoji_picker_category_recent">"최근 이모지"</string>
<string name="emoji_picker_category_symbols">"상징"</string>
<string name="screen_image_edition_a11y_rotate_to_the_left">"이미지를 왼쪽으로 회전"</string>
<plurals name="screen_image_edition_a11y_rotation_state">
<item quantity="other">"%1$d도"</item>
</plurals>
<string name="screen_image_edition_title">"사진 편집"</string>
<string name="screen_media_upload_preview_caption_warning">"캡션은 오래된 앱을 사용하는 사용자에게 표시되지 않을 수 있습니다."</string>
<string name="screen_media_upload_preview_change_video_quality_prompt">"비디오 업로드 품질을 변경하려면 탭하세요"</string>
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"파일을 업로드할 수 없습니다."</string>
@@ -26,6 +31,7 @@
<string name="screen_media_upload_preview_item_count">"전체 %2$d개 중 %1$d번째 파일"</string>
<string name="screen_media_upload_preview_optimize_image_quality_title">"이미지 품질 최적화"</string>
<string name="screen_media_upload_preview_processing">"처리 중…"</string>
<string name="screen_media_upload_preview_title">"미디어 추가"</string>
<string name="screen_report_content_block_user">"사용자 차단하기"</string>
<string name="screen_report_content_block_user_hint">"이 사용자의 현재 및 향후 모든 메시지를 숨기려면 확인하세요."</string>
<string name="screen_report_content_explanation">"이 메시지는 홈서버의 관리자에게 보고되었습니다. 암호화된 메시지는 읽을 수 없습니다."</string>
@@ -16,6 +16,12 @@
<string name="emoji_picker_category_places">"Podróż i miejsca"</string>
<string name="emoji_picker_category_recent">"Ostatnie emoji"</string>
<string name="emoji_picker_category_symbols">"Symbole"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally">"Odwróć obraz w poziomie"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Odwrócono w poziomie"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Oryginalny"</string>
<string name="screen_image_edition_a11y_flip_image_vertically">"Odwróć obraz w pionie"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_flipped">"Odwrócono w pionie"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_original">"Oryginalny"</string>
<string name="screen_image_edition_a11y_rotate_to_the_left">"Obróć obraz w lewo"</string>
<plurals name="screen_image_edition_a11y_rotation_state">
<item quantity="one">"%1$d stopień"</item>
@@ -16,6 +16,12 @@
<string name="emoji_picker_category_places">"文旅景点"</string>
<string name="emoji_picker_category_recent">"最近的 Emoji"</string>
<string name="emoji_picker_category_symbols">"符号"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally">"水平翻转图像"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"水平翻转"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"原始"</string>
<string name="screen_image_edition_a11y_flip_image_vertically">"垂直翻转图像"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_flipped">"垂直翻转"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_original">"原始"</string>
<string name="screen_image_edition_a11y_rotate_to_the_left">"向左旋转图像"</string>
<plurals name="screen_image_edition_a11y_rotation_state">
<item quantity="other">"%1$d 度"</item>
@@ -16,6 +16,12 @@
<string name="emoji_picker_category_places">"Travel &amp; Places"</string>
<string name="emoji_picker_category_recent">"Recent emojis"</string>
<string name="emoji_picker_category_symbols">"Symbols"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally">"Flip image horizontally"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Flipped horizontally"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Original"</string>
<string name="screen_image_edition_a11y_flip_image_vertically">"Flip image vertically"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_flipped">"Flipped vertically"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_original">"Original"</string>
<string name="screen_image_edition_a11y_rotate_to_the_left">"Rotate the image to the left"</string>
<plurals name="screen_image_edition_a11y_rotation_state">
<item quantity="one">"%1$d degree"</item>
@@ -23,6 +23,7 @@ import io.element.android.features.messages.api.MessagesEntryPoint
import io.element.android.features.messages.impl.pinned.banner.createPinnedEventsTimelineProvider
import io.element.android.features.messages.impl.timeline.createTimelineController
import io.element.android.features.poll.test.create.FakeCreatePollEntryPoint
import io.element.android.libraries.androidutils.system.DeviceHasVulkanSupport
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
@@ -42,6 +43,7 @@ import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode
import io.element.android.tests.testutils.testCoroutineDispatchers
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@@ -85,6 +87,7 @@ class DefaultMessagesEntryPointTest {
knockRequestsListEntryPoint = FakeKnockRequestsListEntryPoint(),
dateFormatter = FakeDateFormatter(),
coroutineDispatchers = testCoroutineDispatchers(),
hasVulkanSupport = DeviceHasVulkanSupport(mockk(relaxed = true))
)
}
val callback = object : MessagesEntryPoint.Callback {
@@ -238,7 +238,6 @@ class MessagesViewTest {
val eventsRecorder = EventsRecorder<ReadReceiptBottomSheetEvent>()
val state = aMessagesState(
timelineState = aTimelineState(
renderReadReceipts = true,
timelineItems = persistentListOf(
aTimelineItemEvent(
readReceiptState = aTimelineItemReadReceipts(
@@ -624,7 +624,11 @@ class AttachmentsPreviewPresenterTest {
val croppedState = awaitItem()
croppedState.eventSink(AttachmentsPreviewEvent.RotateImageToTheLeft)
val rotatedState = awaitItem()
rotatedState.eventSink(AttachmentsPreviewEvent.ApplyImageEdits)
rotatedState.eventSink(AttachmentsPreviewEvent.FlipImageHorizontally)
val flippedHorizontallyState = awaitItem()
flippedHorizontallyState.eventSink(AttachmentsPreviewEvent.FlipImageVertically)
val flippedState = awaitItem()
flippedState.eventSink(AttachmentsPreviewEvent.ApplyImageEdits)
val appliedState = consumeItemsUntilPredicate { !it.isApplyingImageEdits && it.imageEditorState == null }.last()
assertThat((appliedState.attachment as Attachment.Media).localMedia.uri).isEqualTo(editedUri)
@@ -638,9 +642,36 @@ class AttachmentsPreviewPresenterTest {
right = cropRect.bottom,
bottom = 1f - cropRect.left,
)
reopenedState.imageEditorState.edits.cropRect.assertIsSimilarTo(rotatedCropRect)
val flippedCropRect = NormalizedCropRect(
left = 1f - rotatedCropRect.right,
top = 1f - rotatedCropRect.bottom,
right = 1f - rotatedCropRect.left,
bottom = 1f - rotatedCropRect.top,
)
reopenedState.imageEditorState.edits.cropRect.assertIsSimilarTo(flippedCropRect)
assertThat(reopenedState.imageEditorState.edits.rotationQuarterTurns).isEqualTo(3)
assertThat(reopenedState.imageEditorState.edits.rotationDegrees).isEqualTo(270)
assertThat(reopenedState.imageEditorState.edits.isFlippedHorizontally).isTrue()
assertThat(reopenedState.imageEditorState.edits.isFlippedVertically).isTrue()
}
}
@Test
fun `present - image editor flip events update edits`() = runTest {
val presenter = createAttachmentsPreviewPresenter(displayMediaQualitySelectorViews = true)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(AttachmentsPreviewEvent.OpenImageEditor)
val editorState = consumeItemsUntilPredicate { it.imageEditorState != null }.last()
editorState.eventSink(AttachmentsPreviewEvent.FlipImageHorizontally)
val flippedHorizontallyState = awaitItem()
assertThat(flippedHorizontallyState.imageEditorState?.edits?.isFlippedHorizontally).isTrue()
flippedHorizontallyState.eventSink(AttachmentsPreviewEvent.FlipImageVertically)
val flippedState = awaitItem()
assertThat(flippedState.imageEditorState?.edits?.isFlippedVertically).isTrue()
}
}
@@ -42,4 +42,37 @@ class AttachmentImageEditsTest {
assertThat(result.cropRect.bottom).isWithin(0.0001f).of(0.8f)
assertThat(result.hasChanges).isTrue()
}
@Test
fun `flip horizontally updates crop and change tracking`() {
val sut = AttachmentImageEdits(
cropRect = NormalizedCropRect(
left = 0.1f,
top = 0.3f,
right = 0.6f,
bottom = 0.9f,
)
)
val result = sut.flipHorizontally()
assertThat(result.isFlippedHorizontally).isTrue()
assertThat(result.cropRect.left).isWithin(0.0001f).of(0.4f)
assertThat(result.cropRect.right).isWithin(0.0001f).of(0.9f)
assertThat(result.cropRect.top).isWithin(0.0001f).of(0.3f)
assertThat(result.cropRect.bottom).isWithin(0.0001f).of(0.9f)
assertThat(result.hasChanges).isTrue()
}
@Test
fun `flip vertical twice resets to default state`() {
val edits = AttachmentImageEdits().flipVertically().flipVertically()
assertThat(edits.isFlippedVertically).isFalse()
assertThat(edits.hasChanges).isFalse()
}
@Test
fun `flip horizontally twice resets to default state`() {
val edits = AttachmentImageEdits().flipHorizontally().flipHorizontally()
assertThat(edits.isFlippedVertically).isFalse()
assertThat(edits.hasChanges).isFalse()
}
}