Merge branch 'develop' into misc/reactions-pinned-messages

This commit is contained in:
bxdxnn
2026-06-26 13:16:16 +03:00
committed by GitHub
2972 changed files with 30300 additions and 13128 deletions
+1
View File
@@ -70,6 +70,7 @@ dependencies {
implementation(libs.jsoup)
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.constraintlayout.compose)
implementation(libs.androidx.exifinterface)
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.ui)
implementation(libs.sigpwned.emoji4j)
@@ -18,6 +18,8 @@ sealed interface MessagesEvent {
data class ToggleReaction(val emoji: String, val eventOrTransactionId: EventOrTransactionId) : MessagesEvent
data class InviteDialogDismissed(val action: InviteDialogAction) : MessagesEvent
data class OnUserClicked(val user: MatrixUser) : MessagesEvent
data object StopLiveLocationShare : MessagesEvent
data object ShowLiveLocationShare : MessagesEvent
data object MarkAsFullyReadAndExit : MessagesEvent
}
@@ -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
@@ -24,11 +27,12 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.Interaction
import io.element.android.annotations.ContributesNode
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.CallData
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(),
@@ -143,6 +149,7 @@ class MessagesFlowNode(
val mediaInfo: MediaInfo,
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val canUseOverlay: Boolean,
) : NavTarget
@Parcelize
@@ -183,10 +190,15 @@ class MessagesFlowNode(
@Parcelize
data object ThreadsList : NavTarget
@Parcelize
data class AvatarPreview(val name: String, val avatarUrl: String) : NavTarget
}
private val callback: MessagesEntryPoint.Callback = callback()
private var displayVulkanNotSupportedError by mutableStateOf(false)
override fun onBuilt() {
super.onBuilt()
lifecycle.subscribe(
@@ -227,10 +239,11 @@ class MessagesFlowNode(
callback.navigateToRoomDetails()
}
override fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean {
override fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean): Boolean {
return processEventClick(
timelineMode = timelineMode,
event = event,
canUseOverlay = canUseOverlay,
)
}
@@ -265,7 +278,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() {
@@ -276,14 +293,22 @@ class MessagesFlowNode(
backstack.push(NavTarget.EditPoll(Timeline.Mode.Live, eventId))
}
override fun navigateToCurrentLiveLocation() {
if (hasVulkanSupport()) {
backstack.push(NavTarget.LocationViewer(ShowLocationMode.Live(senderId = sessionId)))
} else {
displayVulkanNotSupportedError = true
}
}
override fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean) {
val callType = CallType.RoomCall(
val callData = CallData(
sessionId = sessionId,
roomId = roomId,
isAudioCall = isAudioCall
isAudioCall = isAudioCall,
)
analyticsService.captureInteraction(Interaction.Name.MobileRoomCallButton)
elementCallEntryPoint.startCall(callType)
elementCallEntryPoint.startCall(callData)
}
override fun navigateToPinnedMessagesList() {
@@ -305,6 +330,10 @@ class MessagesFlowNode(
override fun navigateToDeveloperSettings() {
callback.navigateToDeveloperSettings()
}
override fun navigateToAvatarPreview(username: String, avatarUrl: String) {
overlay.show(NavTarget.AvatarPreview(username, avatarUrl))
}
}
val inputs = MessagesNode.Inputs(focusedEventId = navTarget.focusedEventId)
createNode<MessagesNode>(buildContext, listOf(callback, inputs))
@@ -320,7 +349,11 @@ class MessagesFlowNode(
)
val callback = object : MediaViewerEntryPoint.Callback {
override fun onDone() {
overlay.hide()
if (navTarget.canUseOverlay) {
overlay.hide()
} else {
backstack.pop()
}
}
override fun viewInTimeline(eventId: EventId) {
@@ -414,10 +447,11 @@ class MessagesFlowNode(
}
NavTarget.PinnedMessagesList -> {
val callback = object : PinnedMessagesListNode.Callback {
override fun handleEventClick(event: TimelineItem.Event) {
override fun handleEventClick(event: TimelineItem.Event, canUseOverlay: Boolean) {
processEventClick(
timelineMode = Timeline.Mode.PinnedEvents,
event = event,
canUseOverlay = canUseOverlay,
)
}
@@ -456,10 +490,11 @@ class MessagesFlowNode(
focusedEventId = navTarget.focusedEventId,
)
val callback = object : ThreadedMessagesNode.Callback {
override fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean {
override fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean): Boolean {
return processEventClick(
timelineMode = timelineMode,
event = event,
canUseOverlay = canUseOverlay,
)
}
@@ -494,7 +529,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() {
@@ -505,14 +544,22 @@ class MessagesFlowNode(
backstack.push(NavTarget.EditPoll(Timeline.Mode.Thread(navTarget.threadRootId), eventId))
}
override fun navigateToCurrentLiveLocation() {
if (hasVulkanSupport()) {
backstack.push(NavTarget.LocationViewer(ShowLocationMode.Live(senderId = sessionId)))
} else {
displayVulkanNotSupportedError = true
}
}
override fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean) {
val callType = CallType.RoomCall(
val callData = CallData(
sessionId = sessionId,
roomId = roomId,
isAudioCall = isAudioCall
)
analyticsService.captureInteraction(Interaction.Name.MobileRoomCallButton)
elementCallEntryPoint.startCall(callType)
elementCallEntryPoint.startCall(callData)
}
override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) {
@@ -522,6 +569,10 @@ class MessagesFlowNode(
override fun navigateToDeveloperSettings() {
callback.navigateToDeveloperSettings()
}
override fun navigateToAvatarPreview(username: String, avatarUrl: String) {
overlay.show(NavTarget.AvatarPreview(username, avatarUrl))
}
}
createNode<ThreadedMessagesNode>(buildContext, listOf(inputs, callback))
}
@@ -533,6 +584,31 @@ class MessagesFlowNode(
}
createNode<ThreadsListNode>(buildContext, listOf(callback))
}
is NavTarget.AvatarPreview -> {
val callback = object : MediaViewerEntryPoint.Callback {
override fun onDone() {
overlay.hide()
}
override fun viewInTimeline(eventId: EventId) {
// Cannot happen
}
override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) {
// Cannot happen
}
}
val params = mediaViewerEntryPoint.createParamsForAvatar(
filename = navTarget.name,
avatarUrl = navTarget.avatarUrl,
)
mediaViewerEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
params = params,
callback = callback,
)
}
}
}
@@ -547,6 +623,7 @@ class MessagesFlowNode(
private fun processEventClick(
timelineMode: Timeline.Mode,
event: TimelineItem.Event,
canUseOverlay: Boolean,
): Boolean {
val navTarget = when (event.content) {
is TimelineItemImageContent -> {
@@ -556,6 +633,7 @@ class MessagesFlowNode(
content = event.content,
mediaSource = event.content.mediaSource,
thumbnailSource = event.content.thumbnailSource,
canUseOverlay = canUseOverlay,
)
}
is TimelineItemVideoContent -> {
@@ -565,6 +643,7 @@ class MessagesFlowNode(
content = event.content,
mediaSource = event.content.mediaSource,
thumbnailSource = event.content.thumbnailSource,
canUseOverlay = canUseOverlay,
)
}
is TimelineItemFileContent -> {
@@ -574,6 +653,7 @@ class MessagesFlowNode(
content = event.content,
mediaSource = event.content.mediaSource,
thumbnailSource = event.content.thumbnailSource,
canUseOverlay = canUseOverlay,
)
}
is TimelineItemAudioContent -> {
@@ -583,27 +663,37 @@ class MessagesFlowNode(
content = event.content,
mediaSource = event.content.mediaSource,
thumbnailSource = null,
canUseOverlay = canUseOverlay,
)
}
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
}
return when (navTarget) {
is NavTarget.MediaViewer -> {
overlay.show(navTarget)
if (canUseOverlay) {
overlay.show(navTarget)
} else {
backstack.push(navTarget)
}
true
}
is NavTarget.LocationViewer -> {
@@ -620,6 +710,7 @@ class MessagesFlowNode(
content: TimelineItemEventContentWithAttachment,
mediaSource: MediaSource,
thumbnailSource: MediaSource?,
canUseOverlay: Boolean,
): NavTarget {
return NavTarget.MediaViewer(
mode = mode,
@@ -628,6 +719,7 @@ class MessagesFlowNode(
filename = content.filename,
fileSize = content.fileSize,
caption = content.caption,
formattedCaption = content.formattedCaption,
mimeType = content.mimeType,
formattedFileSize = content.formattedFileSize,
fileExtension = content.fileExtension,
@@ -647,6 +739,7 @@ class MessagesFlowNode(
),
mediaSource = mediaSource,
thumbnailSource = thumbnailSource,
canUseOverlay = canUseOverlay,
)
}
@@ -663,6 +756,11 @@ class MessagesFlowNode(
@Composable
override fun View(modifier: Modifier) {
mentionSpanTheme.updateStyles()
if (displayVulkanNotSupportedError) {
RenderingMapsNotSupportedDialog { displayVulkanNotSupportedError = false }
}
CompositionLocalProvider(
LocalMentionSpanUpdater provides mentionSpanUpdater
) {
@@ -26,5 +26,6 @@ interface MessagesNavigator {
fun navigateToMember(userId: UserId)
fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?)
fun navigateToDeveloperSettings()
fun navigateToCurrentLiveLocation()
fun close()
}
@@ -66,8 +66,11 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.alias.matches
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.ui.model.getBestName
import io.element.android.libraries.mediaplayer.api.MediaPlayer
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.a11y.hasExternalKeyboard
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.LoadMessagesUi
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.finishLongRunningTransaction
@@ -105,7 +108,7 @@ class MessagesNode(
private val timelineController = TimelineController(room, room.liveTimeline)
private val presenter = presenterFactory.create(
navigator = this,
composerPresenter = messageComposerPresenterFactory.create(timelineController, this, isInThread = false),
composerPresenter = messageComposerPresenterFactory.create(timelineController, this, threadRoot = null),
timelinePresenter = timelinePresenterFactory.create(timelineController = timelineController, this),
actionListPresenter = actionListPresenterFactory.create(
postProcessor = TimelineItemActionPostProcessor.Default,
@@ -115,7 +118,7 @@ class MessagesNode(
)
interface Callback : Plugin {
fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean
fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean): Boolean
fun navigateToPreviewAttachments(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?)
fun navigateToRoomMemberDetails(userId: UserId)
fun handlePermalinkClick(data: PermalinkData)
@@ -125,6 +128,7 @@ class MessagesNode(
fun navigateToSendLocation()
fun navigateToCreatePoll()
fun navigateToEditPoll(eventId: EventId)
fun navigateToCurrentLiveLocation()
fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean)
fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?)
fun navigateToRoomDetails()
@@ -133,6 +137,8 @@ class MessagesNode(
fun navigateToDeveloperSettings()
fun navigateToThreadsList()
fun navigateToAvatarPreview(username: String, avatarUrl: String)
}
override fun onBuilt() {
@@ -237,6 +243,10 @@ class MessagesNode(
callback.navigateToDeveloperSettings()
}
override fun navigateToCurrentLiveLocation() {
callback.navigateToCurrentLiveLocation()
}
private fun displaySameRoomToast() {
context.toast(CommonStrings.screen_room_permalink_same_room_android)
}
@@ -247,6 +257,7 @@ class MessagesNode(
override fun View(modifier: Modifier) {
val activity = requireNotNull(LocalActivity.current)
val isDark = ElementTheme.isLightTheme.not()
val canUseOverlay = !isTalkbackActive() && !hasExternalKeyboard()
CompositionLocalProvider(
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
) {
@@ -268,11 +279,11 @@ class MessagesNode(
onRoomDetailsClick = callback::navigateToRoomDetails,
onEventContentClick = { isLive, event ->
if (isLive) {
callback.handleEventClick(timelineController.mainTimelineMode(), event)
callback.handleEventClick(timelineController.mainTimelineMode(), event, canUseOverlay)
} else {
val detachedTimelineMode = timelineController.detachedTimelineMode()
if (detachedTimelineMode != null) {
callback.handleEventClick(detachedTimelineMode, event)
callback.handleEventClick(detachedTimelineMode, event, canUseOverlay)
} else {
false
}
@@ -311,6 +322,11 @@ class MessagesNode(
else -> state.roomMemberModerationState.eventSink(RoomMemberModerationEvents.ProcessAction(action, target))
}
},
onAvatarClick = { user ->
user.avatarUrl?.let { url ->
callback.navigateToAvatarPreview(user.getBestName(), url)
}
},
modifier = Modifier,
)
@@ -27,6 +27,8 @@ import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.PinUnpinAction
import io.element.android.appconfig.MessageComposerConfig
import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
import io.element.android.features.location.api.live.isCurrentlySharing
import io.element.android.features.messages.api.timeline.HtmlConverterProvider
import io.element.android.features.messages.impl.MessagesState.Threads
import io.element.android.features.messages.impl.actionlist.ActionListState
@@ -77,8 +79,8 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.ui.messages.reply.map
import io.element.android.libraries.matrix.ui.model.getAvatarData
@@ -126,6 +128,7 @@ class MessagesPresenter(
private val featureFlagService: FeatureFlagService,
private val addRecentEmoji: AddRecentEmoji,
private val markAsFullyRead: MarkAsFullyRead,
private val liveLocationShareManager: ActiveLiveLocationShareManager,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
) : Presenter<MessagesState> {
@AssistedFactory
@@ -172,6 +175,7 @@ class MessagesPresenter(
}
val canOpenThreadList by featureFlagService.isFeatureEnabledFlow(FeatureFlags.RoomThreadList).collectAsState(initial = false)
val isCurrentlySharingLiveLocationInRoom by remember { liveLocationShareManager.isCurrentlySharing(room.roomId) }.collectAsState()
val userEventPermissions by room.permissionsAsState(UserEventPermissions.DEFAULT) { perms ->
perms.userEventPermissions()
@@ -260,6 +264,18 @@ class MessagesPresenter(
is MessagesEvent.OnUserClicked -> {
roomMemberModerationState.eventSink(RoomMemberModerationEvents.ShowActionsForUser(event.user))
}
MessagesEvent.StopLiveLocationShare -> {
localCoroutineScope.launch {
liveLocationShareManager.stopShare(room.roomId)
.onFailure {
Timber.e(it, "Failed to stop live location share for roomId=${room.roomId}")
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_error))
}
}
}
MessagesEvent.ShowLiveLocationShare -> {
navigator.navigateToCurrentLiveLocation()
}
is MessagesEvent.MarkAsFullyReadAndExit -> if (!markingAsReadAndExiting.getAndSet(true)) {
coroutineScope.launch {
val latestEventId = room.liveTimeline.getLatestEventId().getOrElse {
@@ -272,6 +288,8 @@ class MessagesPresenter(
}
}
navigator.close()
}.invokeOnCompletion {
markingAsReadAndExiting.set(false)
}
}
}
@@ -309,6 +327,7 @@ class MessagesPresenter(
// TODO calculate this properly based on the thread list and the read state of each thread
hasUnreadThreads = false,
),
showLiveLocationShareBanner = isCurrentlySharingLiveLocationInRoom && timelineState.timelineMode !is Timeline.Mode.Thread,
eventSink = ::handleEvent,
)
}
@@ -531,7 +550,7 @@ class MessagesPresenter(
val replyToDetails = loadReplyDetails(targetEvent.eventId).map(permalinkParser)
val composerMode = MessageComposerMode.Reply(
replyToDetails = replyToDetails,
hideImage = timelineProtectionState.hideMediaContent(targetEvent.eventId),
hideImage = timelineProtectionState.hideMediaContent(targetEvent.eventId, targetEvent.isMine),
)
composerState.eventSink(
MessageComposerEvent.SetMode(composerMode)
@@ -58,6 +58,7 @@ data class MessagesState(
val topBarSharedHistoryIcon: SharedHistoryIcon,
val successorRoom: SuccessorRoom?,
val threads: Threads,
val showLiveLocationShareBanner: Boolean,
val eventSink: (MessagesEvent) -> Unit
) {
val isTombstoned = successorRoom != null
@@ -44,6 +44,7 @@ import io.element.android.features.roommembermoderation.api.RoomMemberModeration
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ROOM_NAME
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
@@ -79,6 +80,7 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
currentPinnedMessageIndex = 0,
),
),
aMessagesState(isCurrentlySharingLiveLocationInRoom = true),
aMessagesState(successorRoom = SuccessorRoom(RoomId("!id:domain"), null)),
aMessagesState(
timelineState = aTimelineState(
@@ -94,8 +96,8 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
}
fun aMessagesState(
roomName: String? = "Room name",
roomAvatar: AvatarData = AvatarData("!id:domain", "Room name", size = AvatarSize.TimelineRoom),
roomName: String? = ROOM_NAME,
roomAvatar: AvatarData = AvatarData("!id:domain", ROOM_NAME, size = AvatarSize.TimelineRoom),
userEventPermissions: UserEventPermissions = aUserEventPermissions(),
composerState: MessageComposerState = aMessageComposerState(
textEditorState = aTextEditorStateRich(initialText = "Hello", initialFocus = true),
@@ -126,6 +128,7 @@ fun aMessagesState(
hasThreads = false,
hasUnreadThreads = false,
),
isCurrentlySharingLiveLocationInRoom: Boolean = false,
eventSink: (MessagesEvent) -> Unit = {},
) = MessagesState(
roomId = RoomId("!id:domain"),
@@ -155,6 +158,7 @@ fun aMessagesState(
topBarSharedHistoryIcon = topBarSharedHistoryIcon,
successorRoom = successorRoom,
threads = threads,
showLiveLocationShareBanner = isCurrentlySharingLiveLocationInRoom,
eventSink = eventSink,
)
@@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
@@ -56,6 +57,7 @@ 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.location.api.LiveLocationSharingBanner
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerEvent
import io.element.android.features.messages.impl.actionlist.ActionListEvent
import io.element.android.features.messages.impl.actionlist.ActionListView
@@ -205,15 +207,15 @@ fun MessagesView(
val expandableState = rememberExpandableBottomSheetLayoutState()
ExpandableBottomSheetLayout(
modifier = modifier
.fillMaxSize()
.imePadding()
.systemBarsPadding()
.onSizeChanged { size ->
// Let the composer takes at max half of the available height.
// The value will be different if the soft keyboard is displayed
// or not.
maxComposerHeightPx = (size.height * 0.5f).toInt()
},
.fillMaxSize()
.imePadding()
.systemBarsPadding()
.onSizeChanged { size ->
// Let the composer takes at max half of the available height.
// The value will be different if the soft keyboard is displayed
// or not.
maxComposerHeightPx = (size.height * 0.5f).toInt()
},
content = {
Scaffold(
contentWindowInsets = WindowInsets.statusBars,
@@ -250,8 +252,8 @@ fun MessagesView(
content = { padding ->
Box(
modifier = Modifier
.padding(padding)
.consumeWindowInsets(padding)
.padding(padding)
.consumeWindowInsets(padding)
) {
MessagesViewContent(
state = state,
@@ -288,10 +290,10 @@ fun MessagesView(
SuggestionsPickerView(
modifier = Modifier
.shadow(10.dp)
.background(ElementTheme.colors.bgCanvasDefault)
.align(Alignment.BottomStart)
.heightIn(max = 230.dp),
.shadow(10.dp)
.background(ElementTheme.colors.bgCanvasDefault)
.align(Alignment.BottomStart)
.heightIn(max = 230.dp),
roomId = state.roomId,
roomName = state.roomName,
roomAvatarData = state.roomAvatar,
@@ -411,7 +413,7 @@ fun MessagesView(
}
@Composable
internal fun MessagesMenuActions(
internal fun RowScope.MessagesMenuActions(
displayThreads: Boolean,
roomCallState: RoomCallState,
onJoinCallClick: (isAudioCall: Boolean) -> Unit,
@@ -467,9 +469,9 @@ private fun MessagesViewContent(
) {
Box(
modifier = modifier
.fillMaxSize()
.navigationBarsPadding()
.imePadding(),
.fillMaxSize()
.navigationBarsPadding()
.imePadding(),
) {
AttachmentsBottomSheet(
state = state.composerState,
@@ -520,25 +522,34 @@ private fun MessagesViewContent(
)
if (state.timelineState.timelineMode !is Timeline.Mode.Thread) {
AnimatedVisibility(
visible = state.pinnedMessagesBannerState is PinnedMessagesBannerState.Visible && scrollBehavior.isVisible,
modifier = Modifier.onSizeChanged { pinnedBannerHeightDp = with(density) { it.height.toDp() } },
enter = expandVertically(),
exit = shrinkVertically(),
) {
fun focusOnPinnedEvent(eventId: EventId) {
state.timelineState.eventSink(
TimelineEvent.FocusOnEvent(eventId = eventId, debounce = FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS.milliseconds)
Column {
AnimatedVisibility(
visible = state.pinnedMessagesBannerState is PinnedMessagesBannerState.Visible && scrollBehavior.isVisible,
modifier = Modifier.onSizeChanged { pinnedBannerHeightDp = with(density) { it.height.toDp() } },
enter = expandVertically(),
exit = shrinkVertically(),
) {
fun focusOnPinnedEvent(eventId: EventId) {
state.timelineState.eventSink(
TimelineEvent.FocusOnEvent(eventId = eventId, debounce = FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS.milliseconds)
)
}
PinnedMessagesBannerView(
state = state.pinnedMessagesBannerState,
onClick = ::focusOnPinnedEvent,
onViewAllClick = onViewAllPinnedMessagesClick,
)
}
if (state.showLiveLocationShareBanner) {
LiveLocationSharingBanner(
onClick = { state.eventSink(MessagesEvent.ShowLiveLocationShare) },
onStopClick = { state.eventSink(MessagesEvent.StopLiveLocationShare) }
)
}
PinnedMessagesBannerView(
state = state.pinnedMessagesBannerState,
onClick = ::focusOnPinnedEvent,
onViewAllClick = onViewAllPinnedMessagesClick,
)
}
knockRequestsBannerView()
}
knockRequestsBannerView()
}
}
}
@@ -587,9 +598,9 @@ private fun MessagesViewComposerBottomSheetContents(
private fun CantSendMessageBanner() {
Row(
modifier = Modifier
.fillMaxWidth()
.background(ElementTheme.colors.bgSubtleSecondary)
.padding(16.dp),
.fillMaxWidth()
.background(ElementTheme.colors.bgSubtleSecondary)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
@@ -15,6 +15,7 @@ import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUser
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.anUnsignedDeviceSendFailure
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
import io.element.android.features.messages.impl.timeline.model.event.aStaticLocationMode
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
@@ -127,7 +128,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
anActionListState(
target = ActionListState.Target.Success(
event = aTimelineItemEvent(
content = aTimelineItemLocationContent(),
content = aTimelineItemLocationContent(mode = aStaticLocationMode()),
timelineItemReactions = reactionsState
),
sentTimeFull = "January 1, 1970 at 12:00 AM",
@@ -140,7 +141,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
anActionListState(
target = ActionListState.Target.Success(
event = aTimelineItemEvent(
content = aTimelineItemLocationContent(),
content = aTimelineItemLocationContent(mode = aStaticLocationMode()),
timelineItemReactions = reactionsState
),
sentTimeFull = "January 1, 1970 at 12:00 AM",
@@ -249,7 +249,7 @@ private fun ActionListViewContent(
leadingContent = ListItemContent.Icon(IconSource.Resource(action.icon)),
style = when {
action.destructive -> ListItemStyle.Destructive
else -> ListItemStyle.Primary
else -> ListItemStyle.Default
}
)
}
@@ -16,5 +16,12 @@ import kotlinx.parcelize.Parcelize
@Immutable
sealed interface Attachment : Parcelable {
@Parcelize
data class Media(val localMedia: LocalMedia) : Attachment
data class Media(
val localMedia: LocalMedia,
// When true, the media was picked through the "Files" picker and should be
// uploaded without image recompression; videos still use the highest available
// / best-fit preset rather than an additional size-reduction optimization pass.
// See https://github.com/element-hq/element-x-android/issues/6365
val sendAsFile: Boolean = false,
) : Attachment
}
@@ -0,0 +1,44 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.attachments.preview
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAnimatedImage
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
import io.element.android.libraries.mediaviewer.api.MediaInfo
import java.util.Locale
internal fun MediaInfo.canEditImage(): Boolean {
val resolvedMimeType = resolvedImageMimeType() ?: return false
return resolvedMimeType.isMimeTypeImage() &&
!resolvedMimeType.isMimeTypeAnimatedImage() &&
resolvedMimeType != MimeTypes.Svg
}
internal fun MediaInfo.isImageAttachment(): Boolean {
return resolvedImageMimeType().isMimeTypeImage()
}
internal fun MediaInfo.resolvedImageMimeType(): String? {
return mimeType.takeIf { it.isMimeTypeImage() } ?: fileExtension.toImageMimeTypeOrNull()
}
private fun String.toImageMimeTypeOrNull(): String? {
return when (lowercase(Locale.ROOT)) {
"png" -> MimeTypes.Png
"jpg", "jpeg" -> MimeTypes.Jpeg
"gif" -> MimeTypes.Gif
"webp" -> MimeTypes.WebP
"svg" -> MimeTypes.Svg
"bmp" -> "image/bmp"
"heic" -> "image/heic"
"heif" -> "image/heif"
"avif" -> "image/avif"
else -> null
}
}
@@ -8,8 +8,19 @@
package io.element.android.features.messages.impl.attachments.preview
import io.element.android.features.messages.impl.attachments.preview.imageeditor.NormalizedCropRect
sealed interface AttachmentsPreviewEvent {
data object SendAttachment : AttachmentsPreviewEvent
data object CancelAndDismiss : AttachmentsPreviewEvent
data object CancelAndClearSendState : 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
data object ClearImageEditError : AttachmentsPreviewEvent
}
@@ -22,7 +22,12 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.imageeditor.AttachmentImageEditor
import io.element.android.features.messages.impl.attachments.preview.imageeditor.AttachmentImageEditorState
import io.element.android.features.messages.impl.attachments.preview.imageeditor.AttachmentImageEdits
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorPresenter
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState
import io.element.android.features.messages.impl.attachments.video.VideoCompressionPresetSelector
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.androidutils.hash.hash
@@ -30,7 +35,6 @@ import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.coroutine.firstInstanceOf
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.core.EventId
@@ -49,7 +53,9 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
@AssistedInject
class AttachmentsPreviewPresenter(
@@ -60,7 +66,9 @@ class AttachmentsPreviewPresenter(
mediaSenderFactory: MediaSenderFactory,
private val permalinkBuilder: PermalinkBuilder,
private val temporaryUriDeleter: TemporaryUriDeleter,
private val attachmentImageEditor: AttachmentImageEditor,
private val mediaOptimizationSelectorPresenterFactory: MediaOptimizationSelectorPresenter.Factory,
private val videoCompressionPresetSelector: VideoCompressionPresetSelector,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
private val dispatchers: CoroutineDispatchers,
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
@@ -84,6 +92,14 @@ class AttachmentsPreviewPresenter(
val sendActionState = remember {
mutableStateOf<SendActionState>(SendActionState.Idle)
}
val originalLocalMedia = remember { (attachment as Attachment.Media).localMedia }
var currentAttachment by remember { mutableStateOf(attachment) }
var canEditImage by remember { mutableStateOf(originalLocalMedia.info.canEditImage()) }
var imageEditorState by remember { mutableStateOf<AttachmentImageEditorState?>(null) }
var appliedImageEdits by remember { mutableStateOf(AttachmentImageEdits()) }
var isApplyingImageEdits by remember { mutableStateOf(false) }
var displayImageEditError by remember { mutableStateOf(false) }
var editedTempFile by remember { mutableStateOf<File?>(null) }
val markdownTextEditorState = rememberMarkdownTextEditorState(initialText = null, initialFocus = false)
val textEditorState by rememberUpdatedState(
@@ -94,9 +110,12 @@ class AttachmentsPreviewPresenter(
var preprocessMediaJob by remember { mutableStateOf<Job?>(null) }
val mediaAttachment = attachment as Attachment.Media
val mediaAttachment = currentAttachment as Attachment.Media
val mediaOptimizationSelectorPresenter = remember {
mediaOptimizationSelectorPresenterFactory.create(mediaAttachment.localMedia)
mediaOptimizationSelectorPresenterFactory.create(
localMedia = mediaAttachment.localMedia,
sendAsFile = mediaAttachment.sendAsFile,
)
}
val mediaOptimizationSelectorState by rememberUpdatedState(mediaOptimizationSelectorPresenter.present())
@@ -104,24 +123,46 @@ class AttachmentsPreviewPresenter(
var displayFileTooLargeError by remember { mutableStateOf(false) }
LaunchedEffect(mediaOptimizationSelectorState.displayMediaSelectorViews) {
LaunchedEffect(
mediaOptimizationSelectorState.displayMediaSelectorViews,
mediaOptimizationSelectorState.videoSizeEstimations,
currentAttachment,
imageEditorState,
isApplyingImageEdits,
) {
// If the media optimization selector is not displayed, we can pre-process the media
// to prepare it for sending. This is done to avoid blocking the UI thread when the
// user clicks on the send button.
if (mediaOptimizationSelectorState.displayMediaSelectorViews == false) {
preprocessMediaJob = preProcessAttachment(
attachment = attachment,
mediaOptimizationConfig = mediaOptimizationConfigProvider.get(),
@Suppress("ComplexCondition")
if (mediaOptimizationSelectorState.displayMediaSelectorViews == false &&
preprocessMediaJob == null &&
imageEditorState == null &&
!isApplyingImageEdits) {
if (mediaAttachment.localMedia.info.mimeType.isMimeTypeVideo() && mediaOptimizationSelectorState.videoSizeEstimations.dataOrNull() == null) {
Timber.d("Waiting for video size estimations to be able to select the best video compression preset before pre-processing the media")
return@LaunchedEffect
}
val config = getAutoPreprocessMediaOptimizationConfig(
mediaAttachment = mediaAttachment,
mediaOptimizationSelectorState = mediaOptimizationSelectorState,
) ?: return@LaunchedEffect
preprocessMediaJob = coroutineScope.preProcessAttachment(
attachment = currentAttachment,
mediaOptimizationConfig = config,
displayProgress = false,
sendActionState = sendActionState,
)
}
}
LaunchedEffect(originalLocalMedia) {
canEditImage = originalLocalMedia.info.canEditImage() || attachmentImageEditor.canEdit(originalLocalMedia)
}
val maxUploadSize = mediaOptimizationSelectorState.maxUploadSize.dataOrNull()
LaunchedEffect(maxUploadSize) {
// Check file upload size if the media won't be processed for upload
val isImageFile = mediaAttachment.localMedia.info.mimeType.isMimeTypeImage()
val isImageFile = mediaAttachment.localMedia.info.isImageAttachment()
val isVideoFile = mediaAttachment.localMedia.info.mimeType.isMimeTypeVideo()
if (maxUploadSize != null && !(isImageFile || isVideoFile)) {
// If file size is not known, we're permissive and allow sending. The SDK will cancel the upload if needed.
@@ -152,7 +193,7 @@ class AttachmentsPreviewPresenter(
videoCompressionPreset = mediaOptimizationSelectorState.selectedVideoPreset ?: VideoCompressionPreset.STANDARD,
)
preprocessMediaJob = preProcessAttachment(
attachment = attachment,
attachment = currentAttachment,
mediaOptimizationConfig = config,
displayProgress = true,
sendActionState = sendActionState,
@@ -171,6 +212,9 @@ class AttachmentsPreviewPresenter(
val caption = markdownTextEditorState.getMessageMarkdown(permalinkBuilder)
.takeIf { it.isNotEmpty() }
val editedTempFileToDelete = editedTempFile
editedTempFile = null
// If we're supposed to send the media as a background job, we can dismiss this screen already
if (coroutineContext.isActive) {
onDoneListener()
@@ -178,33 +222,36 @@ class AttachmentsPreviewPresenter(
// Send the media using the session coroutine scope so it doesn't matter if this screen or the chat one are closed
sessionCoroutineScope.launch(dispatchers.io) {
sendPreProcessedMedia(
mediaUploadInfo = mediaUploadInfo,
caption = caption,
sendActionState = sendActionState,
dismissAfterSend = false,
inReplyToEventId = inReplyToEventId,
)
// Clean up the pre-processed media after it's been sent
mediaSender.cleanUp()
try {
sendPreProcessedMedia(
mediaUploadInfo = mediaUploadInfo,
caption = caption,
sendActionState = sendActionState,
dismissAfterSend = false,
inReplyToEventId = inReplyToEventId,
)
} finally {
editedTempFileToDelete?.safeDelete()
// Clean up the pre-processed media after it's been sent
mediaSender.cleanUp()
}
}
}
}
AttachmentsPreviewEvent.CancelAndDismiss -> {
displayFileTooLargeError = false
displayImageEditError = false
isApplyingImageEdits = false
// Cancel media preprocessing and sending
preprocessMediaJob?.cancel()
preprocessMediaJob = null
// If we couldn't send the pre-processed media, remove it
mediaSender.cleanUp()
ongoingSendAttachmentJob.value?.cancel()
// Dismiss the screen
dismiss(
attachment,
sendActionState,
)
dismiss(sendActionState, editedTempFile)
}
AttachmentsPreviewEvent.CancelAndClearSendState -> {
// Cancel media sending
@@ -220,11 +267,100 @@ class AttachmentsPreviewPresenter(
SendActionState.Idle
}
}
AttachmentsPreviewEvent.OpenImageEditor -> {
val resolvedCanEditImage = canEditImage || originalLocalMedia.info.canEditImage()
if (resolvedCanEditImage) {
preprocessMediaJob?.cancel()
preprocessMediaJob = null
resetPreparedMedia(sendActionState)
imageEditorState = AttachmentImageEditorState(
localMedia = originalLocalMedia,
edits = appliedImageEdits,
previewDebug = false,
)
}
}
AttachmentsPreviewEvent.CloseImageEditor -> {
imageEditorState = null
}
is AttachmentsPreviewEvent.UpdateImageCropRect -> {
val pendingState = imageEditorState ?: return
imageEditorState = pendingState.copy(
edits = pendingState.edits.copy(cropRect = event.cropRect)
)
}
AttachmentsPreviewEvent.RotateImageToTheLeft -> {
val pendingState = imageEditorState ?: return
imageEditorState = pendingState.copy(
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()
)
}
AttachmentsPreviewEvent.ApplyImageEdits -> {
val pendingState = imageEditorState ?: return
if (!pendingState.edits.hasChanges) {
editedTempFile?.safeDelete()
editedTempFile = null
appliedImageEdits = pendingState.edits
currentAttachment = Attachment.Media(originalLocalMedia)
imageEditorState = null
resetPreparedMedia(sendActionState)
return
}
isApplyingImageEdits = true
displayImageEditError = false
coroutineScope.launch {
val result = withContext(dispatchers.io) {
attachmentImageEditor.exportEdits(
localMedia = originalLocalMedia,
edits = pendingState.edits,
)
}
result.fold(
onSuccess = { editedMedia ->
editedTempFile?.safeDelete()
editedTempFile = editedMedia.file
appliedImageEdits = pendingState.edits
currentAttachment = Attachment.Media(editedMedia.localMedia)
imageEditorState = null
resetPreparedMedia(sendActionState)
},
onFailure = {
Timber.e(it, "Failed to apply image edits")
displayImageEditError = true
}
)
isApplyingImageEdits = false
}
}
AttachmentsPreviewEvent.ClearImageEditError -> {
displayImageEditError = false
}
}
}
return AttachmentsPreviewState(
attachment = attachment,
attachment = currentAttachment,
imageEditorState = imageEditorState,
canEditImage = canEditImage,
isApplyingImageEdits = isApplyingImageEdits,
displayImageEditError = displayImageEditError,
sendActionState = sendActionState.value,
textEditorState = textEditorState,
mediaOptimizationSelectorState = mediaOptimizationSelectorState,
@@ -233,6 +369,28 @@ class AttachmentsPreviewPresenter(
)
}
private suspend fun getAutoPreprocessMediaOptimizationConfig(
mediaAttachment: Attachment.Media,
mediaOptimizationSelectorState: MediaOptimizationSelectorState,
): MediaOptimizationConfig? {
return if (mediaAttachment.sendAsFile) {
// If we're sending the media as a file, we can skip image compression and we should select the highest video compression preset that still fits
// the upload limit (if the estimations are available)
val videoCompressionPreset = videoCompressionPresetSelector.selectBestVideoPreset(
expectedVideoPreset = VideoCompressionPreset.HIGH,
videoSizeEstimations = mediaOptimizationSelectorState.videoSizeEstimations,
).dataOrNull() ?: VideoCompressionPreset.HIGH
MediaOptimizationConfig(
compressImages = false,
videoCompressionPreset = videoCompressionPreset,
)
} else {
// Otherwise, we just rely on the user preferences for media optimization
mediaOptimizationConfigProvider.get()
}
}
private fun CoroutineScope.preProcessAttachment(
attachment: Attachment,
mediaOptimizationConfig: MediaOptimizationConfig,
@@ -279,8 +437,8 @@ class AttachmentsPreviewPresenter(
}
private fun dismiss(
attachment: Attachment,
sendActionState: MutableState<SendActionState>,
editedTempFile: File?,
) {
// Delete the temporary file
when (attachment) {
@@ -291,6 +449,7 @@ class AttachmentsPreviewPresenter(
}
}
}
editedTempFile?.safeDelete()
// Reset the sendActionState to ensure that dialog is closed before the screen
sendActionState.value = SendActionState.Done
onDoneListener()
@@ -304,6 +463,12 @@ class AttachmentsPreviewPresenter(
}
}
private fun resetPreparedMedia(sendActionState: MutableState<SendActionState>) {
sendActionState.value.mediaUploadInfo()?.let(::cleanUp)
mediaSender.cleanUp()
sendActionState.value = SendActionState.Idle
}
private suspend fun sendPreProcessedMedia(
mediaUploadInfo: MediaUploadInfo,
caption: String?,
@@ -10,12 +10,17 @@ package io.element.android.features.messages.impl.attachments.preview
import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.imageeditor.AttachmentImageEditorState
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.textcomposer.model.TextEditorState
data class AttachmentsPreviewState(
val attachment: Attachment,
val imageEditorState: AttachmentImageEditorState?,
val canEditImage: Boolean,
val isApplyingImageEdits: Boolean,
val displayImageEditError: Boolean,
val sendActionState: SendActionState,
val textEditorState: TextEditorState,
val mediaOptimizationSelectorState: MediaOptimizationSelectorState,
@@ -11,6 +11,8 @@ package io.element.android.features.messages.impl.attachments.preview
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.core.net.toUri
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.imageeditor.AttachmentImageEditorState
import io.element.android.features.messages.impl.attachments.preview.imageeditor.anAttachmentImageEditorState
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState
import io.element.android.features.messages.impl.attachments.video.VideoUploadEstimation
import io.element.android.libraries.architecture.AsyncData
@@ -42,6 +44,9 @@ open class AttachmentsPreviewStateProvider : PreviewParameterProvider<Attachment
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.ReadyToUpload(aMediaUploadInfo())),
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Uploading(aMediaUploadInfo())),
anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException("error"), aMediaUploadInfo())),
anAttachmentsPreviewState(
imageEditorState = anAttachmentImageEditorState(),
),
anAttachmentsPreviewState(displayFileTooLargeError = true),
anAttachmentsPreviewState(
mediaInfo = aVideoMediaInfo(),
@@ -64,12 +69,17 @@ fun anAttachmentsPreviewState(
mediaInfo: MediaInfo = anImageMediaInfo(),
textEditorState: TextEditorState = aTextEditorStateMarkdown(),
sendActionState: SendActionState = SendActionState.Idle,
imageEditorState: AttachmentImageEditorState? = null,
mediaOptimizationSelectorState: MediaOptimizationSelectorState = aMediaOptimisationSelectorState(),
displayFileTooLargeError: Boolean = false,
) = AttachmentsPreviewState(
attachment = Attachment.Media(
localMedia = LocalMedia("file://path".toUri(), mediaInfo),
),
imageEditorState = imageEditorState,
canEditImage = true,
isApplyingImageEdits = false,
displayImageEditError = false,
sendActionState = sendActionState,
textEditorState = textEditorState,
mediaOptimizationSelectorState = mediaOptimizationSelectorState,
@@ -31,6 +31,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
@@ -39,11 +41,11 @@ 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
import io.element.android.features.messages.impl.attachments.preview.imageeditor.AttachmentImageEditorView
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorEvent
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState
import io.element.android.features.messages.impl.attachments.video.VideoUploadEstimation
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.ProgressDialogType
@@ -56,6 +58,8 @@ 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
@@ -74,6 +78,9 @@ import io.element.android.wysiwyg.display.TextDisplay
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
/**
* Ref: https://www.figma.com/design/zftpgS6LjiczobJZ1GUNpt/Updates-to-Media---File-Upload?node-id=51-3514
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AttachmentsPreviewView(
@@ -81,6 +88,13 @@ fun AttachmentsPreviewView(
localMediaRenderer: LocalMediaRenderer,
modifier: Modifier = Modifier,
) {
val canShowEditAction = when (state.sendActionState) {
is SendActionState.Sending.Uploading -> false
is SendActionState.Sending.Processing -> !state.sendActionState.displayProgress
SendActionState.Done -> false
else -> true
}
fun postSendAttachment() {
state.eventSink(AttachmentsPreviewEvent.SendAttachment)
}
@@ -93,33 +107,90 @@ fun AttachmentsPreviewView(
state.eventSink(AttachmentsPreviewEvent.CancelAndClearSendState)
}
BackHandler(enabled = state.sendActionState !is SendActionState.Sending.Uploading && state.sendActionState !is SendActionState.Done) {
postCancel()
fun postOpenImageEditor() {
state.eventSink(AttachmentsPreviewEvent.OpenImageEditor)
}
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
navigationIcon = {
BackButton(
imageVector = CompoundIcons.Close(),
onClick = ::postCancel,
)
},
title = {},
fun postCloseImageEditor() {
state.eventSink(AttachmentsPreviewEvent.CloseImageEditor)
}
fun postResetImageEditor() {
state.eventSink(AttachmentsPreviewEvent.ResetImageEdits)
}
fun postApplyImageEdits() {
state.eventSink(AttachmentsPreviewEvent.ApplyImageEdits)
}
BackHandler(enabled = state.sendActionState !is SendActionState.Sending.Uploading && state.sendActionState !is SendActionState.Done) {
if (state.imageEditorState != null) {
postCloseImageEditor()
} else {
postCancel()
}
}
if (state.imageEditorState != null) {
AttachmentImageEditorView(
state = state.imageEditorState,
onCropRectChange = { cropRect ->
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,
modifier = modifier,
)
} else {
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
navigationIcon = {
BackButton(
onClick = ::postCancel,
)
},
title = {
Text(
modifier = Modifier.semantics {
heading()
},
text = stringResource(R.string.screen_media_upload_preview_title),
)
},
actions = {
if (state.canEditImage && canShowEditAction) {
IconButton(
onClick = ::postOpenImageEditor,
) {
Icon(
imageVector = CompoundIcons.Crop(),
contentDescription = stringResource(CommonStrings.action_edit),
)
}
}
}
)
}
) { paddingValues ->
AttachmentPreviewContent(
modifier = Modifier.padding(paddingValues),
state = state,
localMediaRenderer = localMediaRenderer,
onSendClick = ::postSendAttachment,
)
}
) { paddingValues ->
AttachmentPreviewContent(
modifier = Modifier.padding(paddingValues),
state = state,
localMediaRenderer = localMediaRenderer,
onSendClick = ::postSendAttachment,
)
}
AttachmentSendStateView(
sendActionState = state.sendActionState,
isApplyingImageEdits = state.isApplyingImageEdits,
displayImageEditError = state.displayImageEditError,
onDismissImageEditError = { state.eventSink(AttachmentsPreviewEvent.ClearImageEditError) },
onDismissClick = ::postClearSendState,
onRetryClick = ::postSendAttachment
)
@@ -128,36 +199,56 @@ fun AttachmentsPreviewView(
@Composable
private fun AttachmentSendStateView(
sendActionState: SendActionState,
isApplyingImageEdits: Boolean,
displayImageEditError: Boolean,
onDismissImageEditError: () -> Unit,
onDismissClick: () -> Unit,
onRetryClick: () -> Unit
) {
when (sendActionState) {
is SendActionState.Sending.Processing -> {
if (sendActionState.displayProgress) {
when {
isApplyingImageEdits -> {
ProgressDialog(
type = ProgressDialogType.Indeterminate,
text = stringResource(CommonStrings.common_preparing),
showCancelButton = false,
onDismissRequest = {},
)
}
displayImageEditError -> {
AlertDialog(
title = stringResource(CommonStrings.common_error),
content = stringResource(CommonStrings.common_something_went_wrong_message),
onDismiss = onDismissImageEditError,
)
}
else -> when (sendActionState) {
is SendActionState.Sending.Processing -> {
if (sendActionState.displayProgress) {
ProgressDialog(
type = ProgressDialogType.Indeterminate,
text = stringResource(CommonStrings.common_preparing),
showCancelButton = true,
onDismissRequest = onDismissClick,
)
}
}
is SendActionState.Sending.Uploading -> {
ProgressDialog(
type = ProgressDialogType.Indeterminate,
text = stringResource(CommonStrings.common_preparing),
text = stringResource(id = CommonStrings.common_sending),
showCancelButton = true,
onDismissRequest = onDismissClick,
)
}
is SendActionState.Failure -> {
RetryDialog(
content = stringResource(sendAttachmentError(sendActionState.error)),
onDismiss = onDismissClick,
onRetry = onRetryClick
)
}
else -> Unit
}
is SendActionState.Sending.Uploading -> {
ProgressDialog(
type = ProgressDialogType.Indeterminate,
text = stringResource(id = CommonStrings.common_sending),
showCancelButton = true,
onDismissRequest = onDismissClick,
)
}
is SendActionState.Failure -> {
RetryDialog(
content = stringResource(sendAttachmentError(sendActionState.error)),
onDismiss = onDismissClick,
onRetry = onRetryClick
)
}
else -> Unit
}
}
@@ -184,10 +275,10 @@ private fun AttachmentPreviewContent(
}
}
}
val mimeType = (state.attachment as? Attachment.Media)?.localMedia?.info?.mimeType
if (mimeType?.isMimeTypeImage() == true) {
val mediaInfo = (state.attachment as? Attachment.Media)?.localMedia?.info
if (mediaInfo?.isImageAttachment() == true) {
ImageOptimizationSelector(state.mediaOptimizationSelectorState)
} else if (mimeType?.isMimeTypeVideo() == true) {
} else if (mediaInfo?.mimeType?.isMimeTypeVideo() == true) {
VideoPresetSelector(state = state.mediaOptimizationSelectorState)
}
@@ -220,7 +311,8 @@ private fun AttachmentPreviewContent(
private fun ImageOptimizationSelector(state: MediaOptimizationSelectorState) {
if (state.displayMediaSelectorViews == true) {
Row(
modifier = Modifier.fillMaxWidth()
modifier = Modifier
.fillMaxWidth()
.niceClickable {
state.isImageOptimizationEnabled?.let { value ->
state.eventSink(MediaOptimizationSelectorEvent.SelectImageOptimization(!value))
@@ -229,7 +321,9 @@ private fun ImageOptimizationSelector(state: MediaOptimizationSelectorState) {
.padding(horizontal = 16.dp, vertical = 16.dp)
) {
Text(
modifier = Modifier.weight(1f).align(Alignment.CenterVertically),
modifier = Modifier
.weight(1f)
.align(Alignment.CenterVertically),
text = stringResource(R.string.screen_media_upload_preview_optimize_image_quality_title),
style = ElementTheme.typography.fontBodyLgRegular,
)
@@ -255,7 +349,8 @@ private fun VideoPresetSelector(
if (state.displayMediaSelectorViews == true && videoPresets != null && state.selectedVideoPreset != null) {
Column(
modifier = Modifier.fillMaxWidth()
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp)
.niceClickable { state.eventSink(MediaOptimizationSelectorEvent.OpenVideoPresetSelectorDialog) }
) {
@@ -0,0 +1,202 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.attachments.preview.imageeditor
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.net.Uri
import androidx.exifinterface.media.ExifInterface
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.messages.impl.attachments.preview.resolvedImageMimeType
import io.element.android.libraries.androidutils.bitmap.rotateToExifMetadataOrientation
import io.element.android.libraries.androidutils.bitmap.writeBitmap
import io.element.android.libraries.androidutils.file.createTmpFile
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAnimatedImage
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import kotlinx.coroutines.withContext
import java.io.File
import kotlin.math.roundToInt
private const val EDITED_MEDIA_DIR_NAME = "edited-media"
interface AttachmentImageEditor {
suspend fun canEdit(localMedia: LocalMedia): Boolean
suspend fun exportEdits(
localMedia: LocalMedia,
edits: AttachmentImageEdits,
): Result<EditedLocalMedia>
}
data class EditedLocalMedia(
val localMedia: LocalMedia,
val file: File,
)
@ContributesBinding(AppScope::class)
class DefaultAttachmentImageEditor(
@ApplicationContext private val context: Context,
private val dispatchers: CoroutineDispatchers,
) : AttachmentImageEditor {
override suspend fun canEdit(localMedia: LocalMedia): Boolean = withContext(dispatchers.io) {
localMedia.info.resolvedImageMimeType()
?.takeIf { it.isEditableStillImageMimeType() }
?.let { return@withContext true }
val decodedMimeType = context.contentResolver.openInputStream(localMedia.uri)?.use { input ->
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeStream(input, null, options)
options.outMimeType
}
decodedMimeType.isEditableStillImageMimeType()
}
override suspend fun exportEdits(
localMedia: LocalMedia,
edits: AttachmentImageEdits,
): Result<EditedLocalMedia> = withContext(dispatchers.io) {
runCatchingExceptions {
val sourceMimeType = localMedia.info.resolvedImageMimeType() ?: localMedia.info.mimeType
val exportedMimeType = exportedMimeTypeFor(sourceMimeType)
val exifOrientation = context.contentResolver.openInputStream(localMedia.uri)?.let { input ->
input.use {
ExifInterface(it).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_UNDEFINED)
}
} ?: ExifInterface.ORIENTATION_UNDEFINED
val decodedBitmap = context.contentResolver.openInputStream(localMedia.uri)?.use { input ->
BitmapFactory.decodeStream(input)
} ?: error("Unable to decode image from ${localMedia.uri}")
val normalizedBitmap = decodedBitmap.rotateToExifMetadataOrientation(exifOrientation)
if (normalizedBitmap !== decodedBitmap) {
decodedBitmap.recycle()
}
val transformedBitmap = normalizedBitmap.applyEdits(edits)
if (transformedBitmap !== normalizedBitmap) {
normalizedBitmap.recycle()
}
val cropRect = edits.cropRect.toPixelRect(
imageWidth = transformedBitmap.width,
imageHeight = transformedBitmap.height,
)
val isCropUnchanged = cropRect.left == 0 && cropRect.top == 0 &&
cropRect.width() == transformedBitmap.width && cropRect.height() == transformedBitmap.height
val croppedBitmap = if (isCropUnchanged) {
transformedBitmap
} else {
Bitmap.createBitmap(
transformedBitmap,
cropRect.left,
cropRect.top,
cropRect.width(),
cropRect.height(),
)
}
if (croppedBitmap !== transformedBitmap) {
transformedBitmap.recycle()
}
val editedMediaDir = File(context.cacheDir, EDITED_MEDIA_DIR_NAME).apply { mkdirs() }
val outputFile = context.createTmpFile(baseDir = editedMediaDir, extension = compressFileExtension(exportedMimeType))
outputFile.writeBitmap(
bitmap = croppedBitmap,
format = compressFormat(exportedMimeType),
quality = 90,
)
croppedBitmap.recycle()
EditedLocalMedia(
localMedia = localMedia.copy(
uri = Uri.fromFile(outputFile),
info = localMedia.info.copy(mimeType = exportedMimeType),
),
file = outputFile,
)
}
}
}
internal fun exportedMimeTypeFor(sourceMimeType: String?): String {
return if (sourceMimeType == MimeTypes.Png) {
MimeTypes.Png
} else {
MimeTypes.Jpeg
}
}
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 {
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)
}
private data class PixelCropRect(
val left: Int,
val top: Int,
val right: Int,
val bottom: Int,
) {
fun width() = right - left
fun height() = bottom - top
}
private fun NormalizedCropRect.toPixelRect(imageWidth: Int, imageHeight: Int): PixelCropRect {
val leftPx = (left * imageWidth).roundToInt().coerceIn(0, imageWidth - 1)
val topPx = (top * imageHeight).roundToInt().coerceIn(0, imageHeight - 1)
val rightPx = (right * imageWidth).roundToInt().coerceIn(leftPx + 1, imageWidth)
val bottomPx = (bottom * imageHeight).roundToInt().coerceIn(topPx + 1, imageHeight)
return PixelCropRect(
left = leftPx,
top = topPx,
right = rightPx,
bottom = bottomPx,
)
}
private fun compressFormat(mimeType: String) = when (mimeType) {
MimeTypes.Png -> Bitmap.CompressFormat.PNG
else -> Bitmap.CompressFormat.JPEG
}
private fun compressFileExtension(mimeType: String) = when (mimeType) {
MimeTypes.Png -> "png"
else -> "jpeg"
}
private fun String?.isEditableStillImageMimeType(): Boolean {
return this != null &&
this.isMimeTypeImage() &&
!this.isMimeTypeAnimatedImage() &&
this != MimeTypes.Svg
}
@@ -0,0 +1,197 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.attachments.preview.imageeditor
import androidx.annotation.FloatRange
import androidx.compose.runtime.Immutable
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
private const val DEFAULT_CROP_MARGIN = 0f
private const val MIN_CROP_SIZE = 0.1f
@Immutable
data class AttachmentImageEditorState(
val localMedia: LocalMedia,
val edits: AttachmentImageEdits,
// For preview only
val previewDebug: Boolean,
)
@Immutable
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
val rotationDegrees: Int
get() = normalizedRotationQuarterTurns * 90
val hasChanges: Boolean
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 = 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(),
)
}
}
@Immutable
data class NormalizedCropRect(
@FloatRange(from = 0.0, to = 1.0) val left: Float,
@FloatRange(from = 0.0, to = 1.0) val top: Float,
@FloatRange(from = 0.0, to = 1.0) val right: Float,
@FloatRange(from = 0.0, to = 1.0) val bottom: Float,
) {
init {
require(left in 0f..1f)
require(top in 0f..1f)
require(right in 0f..1f)
require(bottom in 0f..1f)
require(left < right)
require(top < bottom)
}
val width: Float
get() = right - left
val height: Float
get() = bottom - top
fun applyChange(
dragTarget: CropDragTarget,
deltaX: Float,
deltaY: Float,
): NormalizedCropRect = when (dragTarget) {
is CropDragTarget.Move -> translate(deltaX, deltaY)
is CropDragTarget.Corner -> dragWithCorner(dragTarget, deltaX, deltaY)
is CropDragTarget.Edge -> dragWithEdge(dragTarget, deltaX, deltaY)
}
private fun translate(deltaX: Float, deltaY: Float): NormalizedCropRect {
val clampedLeft = (left + deltaX).coerceIn(0f, 1f - width)
val clampedTop = (top + deltaY).coerceIn(0f, 1f - height)
return copy(
left = clampedLeft,
top = clampedTop,
right = clampedLeft + width,
bottom = clampedTop + height,
)
}
private fun dragWithCorner(
dragTarget: CropDragTarget.Corner,
deltaX: Float,
deltaY: Float,
) = when (dragTarget) {
CropDragTarget.Corner.TopLeft -> copy(
left = (left + deltaX).coerceIn(0f, right - MIN_CROP_SIZE),
top = (top + deltaY).coerceIn(0f, bottom - MIN_CROP_SIZE),
)
CropDragTarget.Corner.TopRight -> copy(
right = (right + deltaX).coerceIn(left + MIN_CROP_SIZE, 1f),
top = (top + deltaY).coerceIn(0f, bottom - MIN_CROP_SIZE),
)
CropDragTarget.Corner.BottomRight -> copy(
right = (right + deltaX).coerceIn(left + MIN_CROP_SIZE, 1f),
bottom = (bottom + deltaY).coerceIn(top + MIN_CROP_SIZE, 1f),
)
CropDragTarget.Corner.BottomLeft -> copy(
left = (left + deltaX).coerceIn(0f, right - MIN_CROP_SIZE),
bottom = (bottom + deltaY).coerceIn(top + MIN_CROP_SIZE, 1f),
)
}
private fun dragWithEdge(
dragTarget: CropDragTarget.Edge,
deltaX: Float,
deltaY: Float,
) = when (dragTarget) {
CropDragTarget.Edge.Top -> copy(
top = (top + deltaY).coerceIn(0f, bottom - MIN_CROP_SIZE),
)
CropDragTarget.Edge.Right -> copy(
right = (right + deltaX).coerceIn(left + MIN_CROP_SIZE, 1f),
)
CropDragTarget.Edge.Bottom -> copy(
bottom = (bottom + deltaY).coerceIn(top + MIN_CROP_SIZE, 1f),
)
CropDragTarget.Edge.Left -> copy(
left = (left + deltaX).coerceIn(0f, right - MIN_CROP_SIZE),
)
}
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,
top = DEFAULT_CROP_MARGIN,
right = 1f - DEFAULT_CROP_MARGIN,
bottom = 1f - DEFAULT_CROP_MARGIN,
)
}
}
sealed interface CropDragTarget {
data object Move : CropDragTarget
sealed interface Corner : CropDragTarget {
data object TopLeft : Corner
data object TopRight : Corner
data object BottomRight : Corner
data object BottomLeft : Corner
}
sealed interface Edge : CropDragTarget {
data object Top : Edge
data object Right : Edge
data object Bottom : Edge
data object Left : Edge
}
}
@@ -0,0 +1,102 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.attachments.preview.imageeditor
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.core.net.toUri
import io.element.android.libraries.mediaviewer.api.anImageMediaInfo
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
open class AttachmentImageEditorStateProvider : PreviewParameterProvider<AttachmentImageEditorState> {
private val caterpillarCrop = NormalizedCropRect(
left = 0.3f,
top = 0.3f,
right = 0.8f,
bottom = 0.75f,
)
override val values: Sequence<AttachmentImageEditorState>
get() = sequenceOf(
anAttachmentImageEditorState(
edits = AttachmentImageEdits(
// Cheat a bit so that the crop match the sample image size (1024 * 682)
cropRect = 0.17f.let { correction ->
NormalizedCropRect(
left = 0f,
top = correction,
right = 1f,
bottom = 1 - correction,
)
},
),
),
anAttachmentImageEditorState(
edits = AttachmentImageEdits(
cropRect = caterpillarCrop,
),
),
anAttachmentImageEditorState(
edits = AttachmentImageEdits(
cropRect = caterpillarCrop,
),
previewDebug = true,
),
anAttachmentImageEditorState(
edits = AttachmentImageEdits(
cropRect = caterpillarCrop,
).rotateAntiClockwise(),
),
// Small crop
anAttachmentImageEditorState(
edits = AttachmentImageEdits(
cropRect = NormalizedCropRect(
left = 0.3f,
top = 0.6f,
right = 0.4f,
bottom = 0.7f,
),
),
previewDebug = true,
),
// Big crop
anAttachmentImageEditorState(
edits = AttachmentImageEdits(
cropRect = NormalizedCropRect(
left = 0.05f,
top = 0.05f,
right = 0.95f,
bottom = 0.95f,
),
),
previewDebug = true,
),
anAttachmentImageEditorState(
edits = AttachmentImageEdits(
cropRect = caterpillarCrop,
).flipHorizontally(),
),
anAttachmentImageEditorState(
edits = AttachmentImageEdits(
cropRect = caterpillarCrop,
).flipVertically(),
),
)
}
internal fun anAttachmentImageEditorState(
localMedia: LocalMedia = LocalMedia(
uri = "file://preview-image".toUri(),
info = anImageMediaInfo(),
),
edits: AttachmentImageEdits = AttachmentImageEdits(),
previewDebug: Boolean = false,
) = AttachmentImageEditorState(
localMedia = localMedia,
edits = edits,
previewDebug = previewDebug,
)
@@ -0,0 +1,691 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.attachments.preview.imageeditor
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
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.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.boundsInParent
import androidx.compose.ui.layout.onPlaced
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
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.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.text.toPx
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.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.ui.strings.CommonStrings
import kotlin.math.min
private val minHandleTouchRadius = 16.dp
private val maxHandleTouchRadius = 56.dp
/**
* Ref: https://www.figma.com/design/zftpgS6LjiczobJZ1GUNpt/Updates-to-Media---File-Upload?node-id=51-3539
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AttachmentImageEditorView(
state: AttachmentImageEditorState,
onCropRectChange: (NormalizedCropRect) -> Unit,
onRotateClick: () -> Unit,
onFlipHorizontallyClick: () -> Unit,
onFlipVerticallyClick: () -> Unit,
onResetClick: () -> Unit,
onCancelClick: () -> Unit,
onDoneClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val rotateContentDescription = stringResource(R.string.screen_image_edition_a11y_rotate_to_the_left)
val rotationStateDescription = pluralStringResource(
R.plurals.screen_image_edition_a11y_rotation_state,
state.edits.rotationDegrees,
state.edits.rotationDegrees,
)
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 = {
TopAppBar(
navigationIcon = {
BackButton(
imageVector = CompoundIcons.Close(),
onClick = onCancelClick,
)
},
title = {
Text(
modifier = Modifier.semantics {
heading()
},
text = stringResource(R.string.screen_image_edition_title),
)
},
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.background(ElementTheme.colors.bgCanvasDefault)
.padding(paddingValues)
) {
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.Center,
) {
CropEditorCanvas(
state = state,
onCropRectChange = onCropRectChange,
)
}
Row(
modifier = Modifier
.align(Alignment.CenterHorizontally)
.widthIn(max = 360.dp)
.navigationBarsPadding()
.padding(start = 20.dp, top = 18.dp, end = 20.dp, bottom = 18.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.CenterStart,
) {
TextButton(
text = stringResource(CommonStrings.action_reset),
destructive = true,
onClick = onResetClick,
)
}
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
.clearAndSetSemantics {
contentDescription = rotateContentDescription
stateDescription = rotationStateDescription
}
) {
Icon(
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_save),
onClick = onDoneClick,
)
}
}
}
}
}
@Composable
private fun BoxScope.CropEditorCanvas(
state: AttachmentImageEditorState,
onCropRectChange: (NormalizedCropRect) -> Unit,
) {
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) }
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
.padding(20.dp),
) {
val displayedSize = remember(maxWidth, maxHeight, imageSize, rotationQuarterTurns) {
val sourceWidth = imageSize.width.takeIf { it > 0 } ?: 1
val sourceHeight = imageSize.height.takeIf { it > 0 } ?: 1
val aspectRatio = if (rotationQuarterTurns % 2 == 0) {
sourceWidth.toFloat() / sourceHeight.toFloat()
} else {
sourceHeight.toFloat() / sourceWidth.toFloat()
}
fitSize(
containerWidth = constraints.maxWidth.toFloat(),
containerHeight = constraints.maxHeight.toFloat(),
aspectRatio = aspectRatio,
)
}
val density = LocalDensity.current
val displayedWidthDp = with(density) { displayedSize.width.toDp() }
val displayedHeightDp = with(density) { displayedSize.height.toDp() }
val imageLayoutSize = remember(displayedSize, rotationQuarterTurns) {
if (rotationQuarterTurns % 2 == 0) {
displayedSize
} else {
Size(
width = displayedSize.height,
height = displayedSize.width,
)
}
}
val imageLayoutWidthDp = with(density) { imageLayoutSize.width.toDp() }
val imageLayoutHeightDp = with(density) { imageLayoutSize.height.toDp() }
Box(
modifier = Modifier
.size(displayedWidthDp, displayedHeightDp)
.align(Alignment.Center)
.onPlaced {
imageRect = it.boundsInParent()
},
contentAlignment = Alignment.Center,
) {
if (LocalInspectionMode.current) {
Image(
painter = painterResource(id = CommonDrawables.sample_background),
contentDescription = null,
modifier = Modifier
.requiredSize(imageLayoutWidthDp, imageLayoutHeightDp)
.graphicsLayer {
scaleX = flipScaleX
scaleY = flipScaleY
}
.graphicsLayer { rotationZ = rotationQuarterTurns * 90f },
contentScale = ContentScale.Fit,
)
} else {
AsyncImage(
model = state.localMedia.uri,
contentDescription = stringResource(CommonStrings.common_image),
modifier = Modifier
.requiredSize(imageLayoutWidthDp, imageLayoutHeightDp)
.graphicsLayer {
scaleX = flipScaleX
scaleY = flipScaleY
}
.graphicsLayer { rotationZ = rotationQuarterTurns * 90f },
contentScale = ContentScale.Fit,
onState = { painterState ->
if (painterState is AsyncImagePainter.State.Success) {
imageSize = IntSize(
width = painterState.result.image.width,
height = painterState.result.image.height,
)
}
}
)
}
}
val minHandleTouchRadiusPx = minHandleTouchRadius.toPx()
val maxHandleTouchRadiusPx = maxHandleTouchRadius.toPx()
val touchRadiusPx by rememberUpdatedState(
(min(
state.edits.cropRect.width * imageRect.width,
state.edits.cropRect.height * imageRect.height,
) / 4f).coerceIn(
minHandleTouchRadiusPx,
maxHandleTouchRadiusPx,
)
)
var dragTarget by remember { mutableStateOf<CropDragTarget?>(null) }
val latestCropRect by rememberUpdatedState(state.edits.cropRect)
val drawGuidelines = dragTarget == CropDragTarget.Move || state.previewDebug
Box(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectDragGestures(
onDragStart = { offset ->
dragTarget = detectDragTarget(
touchPoint = offset,
imageOffset = imageRect.topLeft,
cropRect = latestCropRect,
canvasSize = Size(imageRect.width, imageRect.height),
handleTouchRadius = touchRadiusPx,
)
},
onDragCancel = {
dragTarget = null
},
onDragEnd = {
dragTarget = null
},
) { change, dragAmount ->
val activeTarget = dragTarget ?: return@detectDragGestures
change.consume()
val gestureAreaWidth = imageRect.width.takeIf { it > 0f } ?: size.width.toFloat()
val gestureAreaHeight = imageRect.height.takeIf { it > 0f } ?: size.height.toFloat()
onCropRectChange(
latestCropRect.applyChange(
dragTarget = activeTarget,
deltaX = dragAmount.x / gestureAreaWidth,
deltaY = dragAmount.y / gestureAreaHeight,
)
)
}
},
contentAlignment = Alignment.Center,
) {
CropOverlay(
imageSize = DpSize(displayedWidthDp, displayedHeightDp),
cropRect = state.edits.cropRect,
drawGuidelines = drawGuidelines,
previewDebug = state.previewDebug,
touchRadiusPx = touchRadiusPx,
dragTarget = dragTarget,
)
}
}
}
@Composable
private fun CropOverlay(
imageSize: DpSize,
cropRect: NormalizedCropRect,
drawGuidelines: Boolean,
previewDebug: Boolean,
touchRadiusPx: Float,
dragTarget: CropDragTarget?,
) {
val borderColor = ElementTheme.colors.iconPrimary
val guideColor = ElementTheme.colors.iconPrimary
Canvas(
modifier = Modifier.size(imageSize.width, imageSize.height)
) {
val cropLeft = cropRect.left * size.width
val cropTop = cropRect.top * size.height
val cropRight = cropRect.right * size.width
val cropBottom = cropRect.bottom * size.height
// Hardcoded black: the crop overlay must always darken the image regardless of theme.
// No semantic token exists for this use case in the Compound design system.
val overlayColor = Color.Black.copy(alpha = 0.48f)
// Overlay above the crop area
drawRect(
color = overlayColor,
topLeft = Offset.Zero,
size = Size(width = size.width, height = cropTop),
)
// Overlay on the left of the crop area
drawRect(
color = overlayColor,
topLeft = Offset(0f, cropTop),
size = Size(width = cropLeft, height = cropBottom - cropTop),
)
// Overlay on the right of the crop area
drawRect(
color = overlayColor,
topLeft = Offset(cropRight, cropTop),
size = Size(width = size.width - cropRight, height = cropBottom - cropTop),
)
// Overlay below the crop area
drawRect(
color = overlayColor,
topLeft = Offset(0f, cropBottom),
size = Size(width = size.width, height = size.height - cropBottom),
)
// Main frame of the crop area
drawRect(
color = borderColor,
topLeft = Offset(cropLeft, cropTop),
size = Size(width = cropRight - cropLeft, height = cropBottom - cropTop),
style = Stroke(width = 1.dp.toPx()),
)
// Guidelines dividing the crop area into 9 equal parts
if (drawGuidelines) {
val thirdWidth = (cropRight - cropLeft) / 3f
val thirdHeight = (cropBottom - cropTop) / 3f
for (index in 1..2) {
val offsetX = cropLeft + thirdWidth * index
val offsetY = cropTop + thirdHeight * index
// Vertical guide line
drawLine(
color = guideColor,
start = Offset(offsetX, cropTop),
end = Offset(offsetX, cropBottom),
strokeWidth = 1.dp.toPx(),
)
// Horizontal guide line
drawLine(
color = guideColor,
start = Offset(cropLeft, offsetY),
end = Offset(cropRight, offsetY),
strokeWidth = 1.dp.toPx(),
)
}
}
// Corner handles
val handleLength = 18.dp.toPx()
val handleOffset = 2.dp.toPx()
// Top left corner
drawCornerHandle(
x = cropLeft - handleOffset,
y = cropTop - handleOffset,
handleLength = handleLength,
color = borderColor,
position = CropDragTarget.Corner.TopLeft,
)
// Top right corner
drawCornerHandle(
x = cropRight + handleOffset,
y = cropTop - handleOffset,
handleLength = handleLength,
color = borderColor,
position = CropDragTarget.Corner.TopRight,
)
// Bottom left corner
drawCornerHandle(
x = cropLeft - handleOffset,
y = cropBottom + handleOffset,
handleLength = handleLength,
color = borderColor,
position = CropDragTarget.Corner.BottomLeft,
)
// Bottom right corner
drawCornerHandle(
x = cropRight + handleOffset,
y = cropBottom + handleOffset,
handleLength = handleLength,
color = borderColor,
position = CropDragTarget.Corner.BottomRight,
)
val handleColor = borderColor
// Top handle
drawEdgeHandle(
center = Offset((cropLeft + cropRight) / 2f, cropTop - handleOffset),
horizontal = true,
handleLength = handleLength,
color = handleColor,
)
// Right handle
drawEdgeHandle(
center = Offset(cropRight + handleOffset, (cropTop + cropBottom) / 2f),
horizontal = false,
handleLength = handleLength,
color = handleColor,
)
// Bottom handle
drawEdgeHandle(
center = Offset((cropLeft + cropRight) / 2f, cropBottom + handleOffset),
horizontal = true,
handleLength = handleLength,
color = handleColor,
)
// Left handle
drawEdgeHandle(
center = Offset(cropLeft - handleOffset, (cropTop + cropBottom) / 2f),
horizontal = false,
handleLength = handleLength,
color = handleColor,
)
if (previewDebug) {
// Draw disk around touchable area
listOf(
CropDragTarget.Edge.Top,
CropDragTarget.Edge.Right,
CropDragTarget.Edge.Bottom,
CropDragTarget.Edge.Left,
CropDragTarget.Corner.TopLeft,
CropDragTarget.Corner.TopRight,
CropDragTarget.Corner.BottomRight,
CropDragTarget.Corner.BottomLeft,
CropDragTarget.Move,
).forEach { target ->
val color = when (target) {
is CropDragTarget.Move -> Color.Red
is CropDragTarget.Corner -> Color.Blue
is CropDragTarget.Edge -> Color.Green
}.copy(alpha = if (dragTarget == target) 9f else 0.5f)
drawCircle(
color = color,
radius = touchRadiusPx,
center = computeOffset(target, cropRect, Size(size.width, size.height)),
)
}
}
}
}
private fun fitSize(
containerWidth: Float,
containerHeight: Float,
aspectRatio: Float,
): Size {
val widthBasedHeight = containerWidth / aspectRatio
return if (widthBasedHeight <= containerHeight) {
Size(width = containerWidth, height = widthBasedHeight)
} else {
Size(width = containerHeight * aspectRatio, height = containerHeight)
}
}
private fun detectDragTarget(
touchPoint: Offset,
imageOffset: Offset,
cropRect: NormalizedCropRect,
canvasSize: Size,
handleTouchRadius: Float,
): CropDragTarget? {
// Give priority on Move (extra detection of the center of crop area)
// to ensure that user can move a small crop, then to corners and at last to edges.
val handlesArea = mapOf(
CropDragTarget.Move to computeOffset(CropDragTarget.Move, cropRect, canvasSize),
CropDragTarget.Corner.TopLeft to computeOffset(CropDragTarget.Corner.TopLeft, cropRect, canvasSize),
CropDragTarget.Corner.TopRight to computeOffset(CropDragTarget.Corner.TopRight, cropRect, canvasSize),
CropDragTarget.Corner.BottomRight to computeOffset(CropDragTarget.Corner.BottomRight, cropRect, canvasSize),
CropDragTarget.Corner.BottomLeft to computeOffset(CropDragTarget.Corner.BottomLeft, cropRect, canvasSize),
CropDragTarget.Edge.Top to computeOffset(CropDragTarget.Edge.Top, cropRect, canvasSize),
CropDragTarget.Edge.Right to computeOffset(CropDragTarget.Edge.Right, cropRect, canvasSize),
CropDragTarget.Edge.Bottom to computeOffset(CropDragTarget.Edge.Bottom, cropRect, canvasSize),
CropDragTarget.Edge.Left to computeOffset(CropDragTarget.Edge.Left, cropRect, canvasSize),
)
handlesArea.forEach { (target, corner) ->
if ((corner - touchPoint + imageOffset).getDistance() <= handleTouchRadius) {
return target
}
}
val cropLeft = imageOffset.x + cropRect.left * canvasSize.width
val cropTop = imageOffset.y + cropRect.top * canvasSize.height
val cropRight = imageOffset.x + cropRect.right * canvasSize.width
val cropBottom = imageOffset.y + cropRect.bottom * canvasSize.height
return if (touchPoint.x in cropLeft..cropRight && touchPoint.y in cropTop..cropBottom) {
CropDragTarget.Move
} else {
null
}
}
private fun computeOffset(
target: CropDragTarget,
cropRect: NormalizedCropRect,
canvasSize: Size,
) = when (target) {
CropDragTarget.Move -> Offset((cropRect.left + cropRect.right) * canvasSize.width / 2f, (cropRect.top + cropRect.bottom) * canvasSize.height / 2f)
CropDragTarget.Corner.TopLeft -> Offset(cropRect.left * canvasSize.width, cropRect.top * canvasSize.height)
CropDragTarget.Edge.Top -> Offset((cropRect.left + cropRect.right) * canvasSize.width / 2f, cropRect.top * canvasSize.height)
CropDragTarget.Corner.TopRight -> Offset(cropRect.right * canvasSize.width, cropRect.top * canvasSize.height)
CropDragTarget.Edge.Right -> Offset(cropRect.right * canvasSize.width, (cropRect.top + cropRect.bottom) * canvasSize.height / 2f)
CropDragTarget.Corner.BottomRight -> Offset(cropRect.right * canvasSize.width, cropRect.bottom * canvasSize.height)
CropDragTarget.Edge.Bottom -> Offset((cropRect.left + cropRect.right) * canvasSize.width / 2f, cropRect.bottom * canvasSize.height)
CropDragTarget.Corner.BottomLeft -> Offset(cropRect.left * canvasSize.width, cropRect.bottom * canvasSize.height)
CropDragTarget.Edge.Left -> Offset(cropRect.left * canvasSize.width, (cropRect.top + cropRect.bottom) * canvasSize.height / 2f)
}
// x and y are the coordinates of the corner
private fun DrawScope.drawCornerHandle(
x: Float,
y: Float,
handleLength: Float,
color: Color,
position: CropDragTarget.Corner,
) {
val strokeWidth = 4.dp.toPx()
val correction = strokeWidth / 2
val horizontalCorrection = if (position.isLeft()) -correction else correction
val horizontalEndX = if (position.isLeft()) x + handleLength else x - handleLength
val verticalEndY = if (position.isTop()) y + handleLength else y - handleLength
val verticalCorrection = if (position.isTop()) -correction else correction
// Horizontal line
drawLine(
color = color,
start = Offset(x + horizontalCorrection, y),
end = Offset(horizontalEndX + horizontalCorrection, y),
strokeWidth = strokeWidth,
)
// Vertical line
drawLine(
color = color,
start = Offset(x, y + verticalCorrection),
end = Offset(x, verticalEndY + verticalCorrection),
strokeWidth = strokeWidth,
)
}
private fun CropDragTarget.Corner.isLeft() = this == CropDragTarget.Corner.TopLeft || this == CropDragTarget.Corner.BottomLeft
private fun CropDragTarget.Corner.isTop() = this == CropDragTarget.Corner.TopLeft || this == CropDragTarget.Corner.TopRight
private fun DrawScope.drawEdgeHandle(
center: Offset,
horizontal: Boolean,
handleLength: Float,
color: Color,
) {
val start = if (horizontal) {
Offset(center.x - handleLength / 2f, center.y)
} else {
Offset(center.x, center.y - handleLength / 2f)
}
val end = if (horizontal) {
Offset(center.x + handleLength / 2f, center.y)
} else {
Offset(center.x, center.y + handleLength / 2f)
}
drawLine(
color = color,
start = start,
end = end,
strokeWidth = 4.dp.toPx(),
)
}
// Only preview in dark, dark theme is forced on the Node.
@Preview
@Composable
internal fun AttachmentImageEditorViewPreview(
@PreviewParameter(AttachmentImageEditorStateProvider::class) state: AttachmentImageEditorState,
) = ElementPreviewDark {
AttachmentImageEditorView(
state = state,
onCropRectChange = {},
onRotateClick = {},
onFlipHorizontallyClick = {},
onFlipVerticallyClick = {},
onResetClick = {},
onCancelClick = {},
onDoneClick = {},
)
}
@@ -37,9 +37,11 @@ import kotlin.math.roundToLong
@AssistedInject
class DefaultMediaOptimizationSelectorPresenter(
@Assisted private val localMedia: LocalMedia,
@Assisted private val sendAsFile: Boolean,
private val maxUploadSizeProvider: MaxUploadSizeProvider,
private val featureFlagService: FeatureFlagService,
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
private val videoCompressionPresetSelector: VideoCompressionPresetSelector,
mediaExtractorFactory: VideoMetadataExtractor.Factory,
) : MediaOptimizationSelectorPresenter {
@ContributesBinding(SessionScope::class)
@@ -47,6 +49,7 @@ class DefaultMediaOptimizationSelectorPresenter(
interface Factory : MediaOptimizationSelectorPresenter.Factory {
override fun create(
localMedia: LocalMedia,
sendAsFile: Boolean,
): DefaultMediaOptimizationSelectorPresenter
}
@@ -55,7 +58,9 @@ class DefaultMediaOptimizationSelectorPresenter(
@Composable
override fun present(): MediaOptimizationSelectorState {
val displayMediaSelectorViews by produceState<Boolean?>(null) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.SelectableMediaQuality)
// When sending as a raw file, never show the optimization selector: images skip
// recompression, while videos use the highest available best-fit preset.
value = !sendAsFile && featureFlagService.isFeatureEnabled(FeatureFlags.SelectableMediaQuality)
}
var displayVideoPresetSelectorDialog by remember { mutableStateOf(false) }
@@ -123,12 +128,23 @@ class DefaultMediaOptimizationSelectorPresenter(
var selectedVideoOptimizationPreset by remember { mutableStateOf<AsyncData<VideoCompressionPreset>>(AsyncData.Loading()) }
LaunchedEffect(videoSizeEstimations.dataOrNull()) {
if (sendAsFile) {
// Send-as-file path: pin to no image compression, and pick the highest-quality
// video preset that still fits the upload limit (we have no true "do not re-encode
// video" path in the pre-processor right now).
selectedImageOptimization = AsyncData.Success(false)
selectedVideoOptimizationPreset = videoCompressionPresetSelector.selectBestVideoPreset(
expectedVideoPreset = VideoCompressionPreset.HIGH,
videoSizeEstimations = videoSizeEstimations,
)
return@LaunchedEffect
}
val mediaOptimizationConfig = mediaOptimizationConfigProvider.get()
selectedImageOptimization = AsyncData.Success(mediaOptimizationConfig.compressImages)
// Find the best video preset based on the default preset and the video size estimations
// Since the estimation for the current preset may be way too large to upload, we check the ones that provide lower file sizes
selectedVideoOptimizationPreset = findBestVideoPreset(
defaultVideoPreset = mediaOptimizationConfig.videoCompressionPreset,
selectedVideoOptimizationPreset = videoCompressionPresetSelector.selectBestVideoPreset(
expectedVideoPreset = mediaOptimizationConfig.videoCompressionPreset,
videoSizeEstimations = videoSizeEstimations,
)
}
@@ -176,20 +192,4 @@ class DefaultMediaOptimizationSelectorPresenter(
eventSink = ::handleEvent,
)
}
private fun findBestVideoPreset(
defaultVideoPreset: VideoCompressionPreset,
videoSizeEstimations: AsyncData<ImmutableList<VideoUploadEstimation>>,
): AsyncData<VideoCompressionPreset> {
val estimations = videoSizeEstimations.dataOrNull() ?: return AsyncData.Loading()
// This will find the best video preset that can be used to produce a video that can be uploaded
val bestEstimation = estimations.find { it.preset.ordinal >= defaultVideoPreset.ordinal && it.canUpload }?.preset
return if (bestEstimation != null) {
AsyncData.Success(bestEstimation)
} else {
AsyncData.Failure(
IllegalStateException("No suitable video preset found for default preset: $defaultVideoPreset")
)
}
}
}
@@ -15,6 +15,7 @@ fun interface MediaOptimizationSelectorPresenter : Presenter<MediaOptimizationSe
interface Factory {
fun create(
localMedia: LocalMedia,
sendAsFile: Boolean,
): MediaOptimizationSelectorPresenter
}
}
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.attachments.video
import dev.zacsweers.metro.Inject
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import kotlinx.collections.immutable.ImmutableList
@Inject
class VideoCompressionPresetSelector {
fun selectBestVideoPreset(
expectedVideoPreset: VideoCompressionPreset,
videoSizeEstimations: AsyncData<ImmutableList<VideoUploadEstimation>>,
): AsyncData<VideoCompressionPreset> {
val estimations = videoSizeEstimations.dataOrNull() ?: return AsyncData.Loading()
val bestEstimation = estimations.find { it.preset.ordinal >= expectedVideoPreset.ordinal && it.canUpload }?.preset
return if (bestEstimation != null) {
AsyncData.Success(bestEstimation)
} else {
AsyncData.Failure(
IllegalStateException("No suitable video preset found for expected preset: $expectedVideoPreset")
)
}
}
}
@@ -30,7 +30,6 @@ interface VideoMetadataExtractor : AutoCloseable {
}
}
@ContributesBinding(AppScope::class)
@AssistedInject
class DefaultVideoMetadataExtractor(
@ApplicationContext private val context: Context,
@@ -11,6 +11,7 @@ package io.element.android.features.messages.impl.crypto.identity
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.ui.room.IdentityRoomMember
@@ -32,7 +33,7 @@ class IdentityChangeStateProvider : PreviewParameterProvider<IdentityChangeState
anIdentityChangeState(
roomMemberIdentityStateChanges = listOf(
aRoomMemberIdentityStateChange(
identityRoomMember = anIdentityRoomMember(displayNameOrDefault = "Alice"),
identityRoomMember = anIdentityRoomMember(displayNameOrDefault = USER_NAME_ALICE),
identityState = IdentityState.VerificationViolation,
),
),
@@ -11,6 +11,7 @@ package io.element.android.features.messages.impl.crypto.sendfailure.resolve
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
open class ResolveVerifiedUserSendFailureStateProvider : PreviewParameterProvider<ResolveVerifiedUserSendFailureState> {
override val values: Sequence<ResolveVerifiedUserSendFailureState>
@@ -37,10 +38,10 @@ fun aResolveVerifiedUserSendFailureState(
eventSink = eventSink
)
fun anUnsignedDeviceSendFailure(userDisplayName: String = "Alice") = VerifiedUserSendFailure.UnsignedDevice.FromOther(
fun anUnsignedDeviceSendFailure(userDisplayName: String = USER_NAME_ALICE) = VerifiedUserSendFailure.UnsignedDevice.FromOther(
userDisplayName = userDisplayName,
)
fun aChangedIdentitySendFailure(userDisplayName: String = "Alice") = VerifiedUserSendFailure.ChangedIdentity(
fun aChangedIdentitySendFailure(userDisplayName: String = USER_NAME_ALICE) = VerifiedUserSendFailure.ChangedIdentity(
userDisplayName = userDisplayName,
)
@@ -34,7 +34,6 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ListItemStyle
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.Text
@@ -106,25 +105,21 @@ private fun AttachmentSourcePickerMenu(
modifier = Modifier.clickable { state.eventSink(MessageComposerEvent.PickAttachmentSource.PhotoFromCamera) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.TakePhoto())),
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_camera_photo)) },
style = ListItemStyle.Primary,
)
ListItem(
modifier = Modifier.clickable { state.eventSink(MessageComposerEvent.PickAttachmentSource.VideoFromCamera) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.VideoCall())),
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_camera_video)) },
style = ListItemStyle.Primary,
)
ListItem(
modifier = Modifier.clickable { state.eventSink(MessageComposerEvent.PickAttachmentSource.FromGallery) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Image())),
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_gallery)) },
style = ListItemStyle.Primary,
)
ListItem(
modifier = Modifier.clickable { state.eventSink(MessageComposerEvent.PickAttachmentSource.FromFiles) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Attachment())),
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_files)) },
style = ListItemStyle.Primary,
)
if (state.canShareLocation) {
ListItem(
@@ -134,7 +129,6 @@ private fun AttachmentSourcePickerMenu(
},
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.LocationPin())),
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_location)) },
style = ListItemStyle.Primary,
)
}
ListItem(
@@ -144,14 +138,12 @@ private fun AttachmentSourcePickerMenu(
},
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Polls())),
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_poll)) },
style = ListItemStyle.Primary,
)
if (enableTextFormatting) {
ListItem(
modifier = Modifier.clickable { state.eventSink(MessageComposerEvent.ToggleTextFormatting(enabled = true)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.TextFormatting())),
headlineContent = { Text(stringResource(R.string.screen_room_attachment_text_formatting)) },
style = ListItemStyle.Primary,
)
}
}
@@ -49,6 +49,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatch
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
@@ -57,7 +58,6 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType
import io.element.android.libraries.matrix.api.room.getDirectRoomMember
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.powerlevels.use
import io.element.android.libraries.matrix.api.timeline.TimelineException
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
@@ -110,7 +110,7 @@ import io.element.android.libraries.core.mimetype.MimeTypes.Any as AnyMimeTypes
class MessageComposerPresenter(
@Assisted private val navigator: MessagesNavigator,
@Assisted private val timelineController: TimelineController,
@Assisted private val isInThread: Boolean,
@Assisted private val threadRoot: ThreadId?,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
private val room: JoinedRoom,
private val mediaPickerProvider: PickerProvider,
@@ -139,10 +139,11 @@ class MessageComposerPresenter(
fun create(
timelineController: TimelineController,
navigator: MessagesNavigator,
isInThread: Boolean,
threadRoot: ThreadId?,
): MessageComposerPresenter
}
private val isInThread = threadRoot != null
private val mediaSender = mediaSenderFactory.create(timelineMode = timelineController.mainTimelineMode())
private val cameraPermissionPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA)
@@ -180,7 +181,7 @@ class MessageComposerPresenter(
handlePickedMedia(uri, mimeType)
}
val filesPicker = mediaPickerProvider.registerFilePicker(AnyMimeTypes) { uri, mimeType ->
handlePickedMedia(uri, mimeType ?: MimeTypes.OctetStream)
handlePickedMedia(uri, mimeType ?: MimeTypes.OctetStream, sendAsFile = true)
}
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri ->
handlePickedMedia(uri, MimeTypes.Jpeg)
@@ -235,8 +236,7 @@ class MessageComposerPresenter(
LaunchedEffect(Unit) {
val draft = draftService.loadDraft(
roomId = room.roomId,
// TODO support threads in composer
threadRoot = null,
threadRoot = threadRoot,
isVolatile = false
)
if (draft != null) {
@@ -572,7 +572,7 @@ class MessageComposerPresenter(
notificationConversationService.onSendMessage(
sessionId = room.sessionId,
roomId = roomInfo.id,
roomName = roomInfo.name ?: roomInfo.id.value,
roomName = roomInfo.name,
roomIsDirect = roomInfo.isDm,
roomAvatarUrl = roomInfo.avatarUrl ?: roomMembers.getDirectRoomMember(roomInfo = roomInfo, sessionId = room.sessionId)?.avatarUrl,
)
@@ -606,6 +606,7 @@ class MessageComposerPresenter(
private fun handlePickedMedia(
uri: Uri?,
mimeType: String? = null,
sendAsFile: Boolean = false,
) {
uri ?: return
val localMedia = localMediaFactory.createFromUri(
@@ -614,7 +615,7 @@ class MessageComposerPresenter(
name = null,
formattedFileSize = null
)
val mediaAttachment = Attachment.Media(localMedia)
val mediaAttachment = Attachment.Media(localMedia, sendAsFile = sendAsFile)
val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
navigator.navigateToPreviewAttachments(persistentListOf(mediaAttachment), inReplyToEventId)
@@ -652,8 +653,7 @@ class MessageComposerPresenter(
roomId = room.roomId,
draft = draft,
isVolatile = isVolatile,
// TODO support threads in composer
threadRoot = null,
threadRoot = threadRoot,
)
}
@@ -816,8 +816,7 @@ class MessageComposerPresenter(
// Use the volatile draft only when coming from edit mode otherwise.
val draft = draftService.loadDraft(
roomId = room.roomId,
// TODO support threads in composer
threadRoot = null,
threadRoot = threadRoot,
isVolatile = true
).takeIf { fromEdit }
if (draft != null) {
@@ -33,6 +33,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarType.Ro
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.USER_NAME_BOB
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.RoomAlias
@@ -189,6 +190,7 @@ internal fun SuggestionsPickerViewPreview() {
isIgnored = false,
role = RoomMember.Role.User,
membershipChangeReason = null,
isServiceMember = false,
)
val anAlias = remember { RoomAlias("#room:domain.org") }
SuggestionsPickerView(
@@ -198,7 +200,7 @@ internal fun SuggestionsPickerViewPreview() {
suggestions = persistentListOf(
ResolvedSuggestion.AtRoom,
ResolvedSuggestion.Member(roomMember),
ResolvedSuggestion.Member(roomMember.copy(userId = UserId("@bob:server.org"), displayName = "Bob")),
ResolvedSuggestion.Member(roomMember.copy(userId = UserId("@bob:server.org"), displayName = USER_NAME_BOB)),
ResolvedSuggestion.Alias(
roomAlias = anAlias,
roomId = RoomId("!room:matrix.org"),
@@ -38,6 +38,8 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.a11y.hasExternalKeyboard
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
@ContributesNode(RoomScope::class)
@AssistedInject
@@ -50,7 +52,7 @@ class PinnedMessagesListNode(
private val permalinkParser: PermalinkParser,
) : Node(buildContext, plugins = plugins), PinnedMessagesListNavigator {
interface Callback : Plugin {
fun handleEventClick(event: TimelineItem.Event)
fun handleEventClick(event: TimelineItem.Event, canUseOverlay: Boolean)
fun navigateToRoomMemberDetails(userId: UserId)
fun viewInTimeline(eventId: EventId)
fun handlePermalinkClick(data: PermalinkData.RoomLink)
@@ -103,6 +105,7 @@ class PinnedMessagesListNode(
@Composable
override fun View(modifier: Modifier) {
val canUseOverlay = !isTalkbackActive() && !hasExternalKeyboard()
CompositionLocalProvider(
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
) {
@@ -113,7 +116,9 @@ class PinnedMessagesListNode(
PinnedMessagesListView(
state = state,
onBackClick = ::navigateUp,
onEventClick = callback::handleEventClick,
onEventClick = {
callback.handleEventClick(it, canUseOverlay)
},
onUserDataClick = { callback.navigateToRoomMemberDetails(it.userId) },
onLinkClick = { link -> onLinkClick(context, link.url) },
onLinkLongClick = {
@@ -46,7 +46,6 @@ import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
@@ -227,7 +226,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)
@@ -237,7 +237,6 @@ private fun PinnedMessagesListLoaded(
timelineItem = timelineItem,
timelineMode = Timeline.Mode.PinnedEvents,
timelineRoomInfo = state.timelineRoomInfo,
renderReadReceipts = false,
timelineProtectionState = state.timelineProtectionState,
isLastOutgoingMessage = false,
focusedEventId = null,
@@ -311,7 +310,7 @@ private fun TimelineItemEventContentViewWrapper(
} else {
TimelineItemEventContentView(
content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId),
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine),
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
@@ -67,7 +67,9 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.alias.matches
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.mediaplayer.api.MediaPlayer
import io.element.android.libraries.matrix.ui.model.getBestName
import io.element.android.libraries.ui.utils.a11y.hasExternalKeyboard
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.collections.immutable.ImmutableList
@@ -86,7 +88,6 @@ class ThreadedMessagesNode(
private val presenterFactory: MessagesPresenter.Factory,
private val actionListPresenterFactory: ActionListPresenter.Factory,
private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
private val mediaPlayer: MediaPlayer,
private val permalinkParser: PermalinkParser,
private val appNavigationStateService: AppNavigationStateService,
private val roomMemberModerationRenderer: RoomMemberModerationRenderer,
@@ -112,7 +113,7 @@ class ThreadedMessagesNode(
this.timelineController = timelineController
return presenterFactory.create(
navigator = this,
composerPresenter = messageComposerPresenterFactory.create(timelineController, this, isInThread = true),
composerPresenter = messageComposerPresenterFactory.create(timelineController, this, threadRoot = inputs.threadRootEventId),
timelinePresenter = timelinePresenterFactory.create(timelineController = timelineController, this),
// TODO add special processor for threaded timeline
actionListPresenter = actionListPresenterFactory.create(
@@ -124,7 +125,7 @@ class ThreadedMessagesNode(
}
interface Callback : Plugin {
fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean
fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean): Boolean
fun navigateToPreviewAttachments(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?)
fun navigateToRoomMemberDetails(userId: UserId)
fun handlePermalinkClick(data: PermalinkData)
@@ -134,9 +135,12 @@ class ThreadedMessagesNode(
fun navigateToSendLocation()
fun navigateToCreatePoll()
fun navigateToEditPoll(eventId: EventId)
fun navigateToCurrentLiveLocation()
fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean)
fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?)
fun navigateToDeveloperSettings()
fun navigateToAvatarPreview(username: String, avatarUrl: String)
}
override fun onBuilt() {
@@ -154,9 +158,6 @@ class ThreadedMessagesNode(
onStop = {
appNavigationStateService.onLeavingThread(id)
},
onDestroy = {
mediaPlayer.close()
}
)
}
@@ -246,12 +247,18 @@ class ThreadedMessagesNode(
callback.navigateToDeveloperSettings()
}
override fun navigateToCurrentLiveLocation() {
// Shouldn't happen because LiveLocationSharingBanner is not shown in threads.
callback.navigateToCurrentLiveLocation()
}
override fun close() = navigateUp()
@Composable
override fun View(modifier: Modifier) {
val activity = requireNotNull(LocalActivity.current)
val isDark = ElementTheme.isLightTheme.not()
val canUseOverlay = !isTalkbackActive() && !hasExternalKeyboard()
CompositionLocalProvider(
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
) {
@@ -271,11 +278,11 @@ class ThreadedMessagesNode(
onEventContentClick = { isLive, event ->
timelineController?.let { controller ->
if (isLive) {
callback.handleEventClick(controller.mainTimelineMode(), event)
callback.handleEventClick(controller.mainTimelineMode(), event, canUseOverlay)
} else {
val detachedTimelineMode = controller.detachedTimelineMode()
if (detachedTimelineMode != null) {
callback.handleEventClick(detachedTimelineMode, event)
callback.handleEventClick(detachedTimelineMode, event, canUseOverlay)
} else {
false
}
@@ -311,6 +318,11 @@ class ThreadedMessagesNode(
else -> state.roomMemberModerationState.eventSink(RoomMemberModerationEvents.ProcessAction(action, target))
}
},
onAvatarClick = { user ->
user.avatarUrl?.let { url ->
callback.navigateToAvatarPreview(user.getBestName(), url)
}
},
modifier = Modifier,
)
@@ -101,9 +101,10 @@ class ThreadsListPresenter(
coroutineScope.launch {
Timber.d("Paginating thread list: $paginationStatus")
threadsListService.paginate()
.onFailure { Timber.w(it, "Failed to paginate thread list") }
}
} else {
Timber.d("Not paginating since there is nothing else to load, current status: $paginationStatus")
Timber.d("Not paginating, current status: $paginationStatus")
}
}
}
@@ -28,9 +28,13 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
@@ -43,6 +47,8 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ROOM_NAME
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Scaffold
@@ -77,7 +83,18 @@ fun ThreadsListView(
topBar = {
TopAppBar(
title = {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
val description = stringResource(
CommonStrings.a11y_threads_in_room,
state.roomName,
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clearAndSetSemantics {
heading()
contentDescription = description
},
) {
Avatar(
avatarData = AvatarData(
id = state.roomId.value,
@@ -141,18 +158,19 @@ private fun ScrollHelper(
listState: LazyListState,
onPaginate: () -> Unit,
) {
val updatedOnPaginate by rememberUpdatedState(onPaginate)
val lastVisibleItemIndex by remember {
derivedStateOf { listState.firstVisibleItemIndex + listState.layoutInfo.visibleItemsInfo.size - 1 }
}
val needsPagination by remember {
val shouldPaginate by remember {
derivedStateOf {
val canLoadNewItems = listState.isScrollInProgress || listState.firstVisibleItemScrollOffset == 0
canLoadNewItems && lastVisibleItemIndex == listState.layoutInfo.totalItemsCount - 1
val canLoadNewItems = listState.isScrollInProgress || listState.layoutInfo.totalItemsCount == 0
canLoadNewItems && lastVisibleItemIndex >= listState.layoutInfo.totalItemsCount - 1
}
}
LaunchedEffect(needsPagination, lastVisibleItemIndex) {
if (needsPagination) {
onPaginate()
LaunchedEffect(shouldPaginate, lastVisibleItemIndex) {
if (shouldPaginate) {
updatedOnPaginate()
delay(400L)
}
}
@@ -303,7 +321,7 @@ internal fun ThreadsListViewPreview() {
ThreadsListView(
state = ThreadsListState(
roomId = RoomId("!room-id:server"),
roomName = "Room name",
roomName = ROOM_NAME,
roomAvatarUrl = null,
threads = List(10) { aThreadListRowItem(threadId = ThreadId("\$thread-$it")) }.toImmutableList(),
isRoomTombstoned = false,
@@ -360,7 +378,7 @@ fun aThreadListItem(
fun aThreadListItemEvent(
threadId: ThreadId = ThreadId("\$a-thread-id"),
senderId: UserId = UserId("@a-user-id:server"),
senderProfile: ProfileDetails = ProfileDetails.Ready(displayName = "Alice", displayNameAmbiguous = false, avatarUrl = null),
senderProfile: ProfileDetails = ProfileDetails.Ready(displayName = USER_NAME_ALICE, displayNameAmbiguous = false, avatarUrl = null),
isOwn: Boolean = false,
content: EventContent = MessageContent(
body = "Hello world!",
@@ -23,6 +23,7 @@ import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
import io.element.android.features.messages.impl.MessagesNavigator
import io.element.android.features.messages.impl.UserEventPermissions
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureEvent
@@ -48,7 +49,6 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.core.asEventId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.api.timeline.ReceiptType
@@ -66,6 +66,7 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@@ -94,6 +95,7 @@ class TimelinePresenter(
private val roomCallStatePresenter: Presenter<RoomCallState>,
private val featureFlagService: FeatureFlagService,
private val analyticsService: AnalyticsService,
private val liveLocationShareManager: ActiveLiveLocationShareManager,
) : Presenter<TimelineState> {
private val tag = "TimelinePresenter"
@@ -136,12 +138,6 @@ class TimelinePresenter(
val messageShieldDialogData: MutableState<MessageShieldData?> = remember { mutableStateOf(null) }
val resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailurePresenter.present()
val isSendPublicReadReceiptsEnabled by remember {
sessionPreferencesStore.isSendPublicReadReceiptsEnabled()
}.collectAsState(initial = true)
val renderReadReceipts by remember {
sessionPreferencesStore.isRenderReadReceiptsEnabled()
}.collectAsState(initial = true)
val isLive by remember {
timelineController.isLive()
}.collectAsState(initial = true)
@@ -149,9 +145,6 @@ class TimelinePresenter(
val displayThreadSummaries by produceState(false) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.Threads)
}
val displayFloatingDateBadge by produceState(false) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.FloatingDateBadge)
}
fun handleEvent(event: TimelineEvent) {
when (event) {
@@ -170,12 +163,15 @@ class TimelinePresenter(
newEventState.value = NewEventState.None
}
Timber.tag(tag).d("## sendReadReceiptIfNeeded firstVisibleIndex: ${event.firstIndex}")
sessionCoroutineScope.sendReadReceiptIfNeeded(
firstVisibleIndex = event.firstIndex,
timelineItems = timelineItems,
lastReadReceiptId = lastReadReceiptId,
readReceiptType = if (isSendPublicReadReceiptsEnabled) ReceiptType.READ else ReceiptType.READ_PRIVATE,
)
sessionCoroutineScope.launch {
val sendPublicReadReceipts = sessionPreferencesStore.isSendPublicReadReceiptsEnabled().first()
sendReadReceiptIfNeeded(
firstVisibleIndex = event.firstIndex,
timelineItems = timelineItems,
lastReadReceiptId = lastReadReceiptId,
readReceiptType = if (sendPublicReadReceipts) ReceiptType.READ else ReceiptType.READ_PRIVATE,
)
}
} else {
newEventState.value = NewEventState.None
}
@@ -200,7 +196,9 @@ class TimelinePresenter(
is TimelineEvent.EditPoll -> {
navigator.navigateToEditPoll(event.pollStartId)
}
is TimelineEvent.StopLiveLocationShare -> Unit
is TimelineEvent.StopLiveLocationShare -> sessionCoroutineScope.launch {
liveLocationShareManager.stopShare(room.roomId)
}
is TimelineEvent.FocusOnEvent -> sessionCoroutineScope.launch {
focusRequestState.value = FocusRequestState.Requested(event.eventId, event.debounce)
delay(event.debounce)
@@ -251,13 +249,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
@@ -312,14 +315,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,
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 eventSink: (TimelineEvent) -> Unit,
) {
private val lastTimelineEvent = timelineItems.firstOrNull { it is TimelineItem.Event } as? TimelineItem.Event
@@ -29,6 +29,8 @@ import io.element.android.features.messages.impl.typing.aTypingNotificationState
import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ROOM_NAME
import io.element.android.libraries.designsystem.preview.USER_NAME_SENDER
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.core.UniqueId
@@ -49,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,
eventSink: (TimelineEvent) -> Unit = {},
): TimelineState {
val focusedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().getOrNull(focusedEventIndex)?.eventId
@@ -69,14 +69,12 @@ fun aTimelineState(
timelineItems = timelineItems,
timelineMode = timelineMode,
timelineRoomInfo = timelineRoomInfo,
renderReadReceipts = renderReadReceipts,
newEventState = NewEventState.None,
isLive = isLive,
focusRequestState = focusRequestState,
messageShieldDialogData = messageShield?.let { MessageShieldData(it) },
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
displayThreadSummaries = displayThreadSummaries,
displayFloatingDateBadge = displayFloatingDateBadge,
eventSink = eventSink,
)
}
@@ -143,7 +141,7 @@ internal fun aTimelineItemEvent(
isMine: Boolean = false,
isEditable: Boolean = false,
canBeRepliedTo: Boolean = false,
senderDisplayName: String = "Sender",
senderDisplayName: String = USER_NAME_SENDER,
displayNameAmbiguous: Boolean = false,
content: TimelineItemEventContent = aTimelineItemTextContent(),
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
@@ -160,7 +158,7 @@ internal fun aTimelineItemEvent(
eventId = eventId,
transactionId = transactionId,
senderId = UserId("@senderId:domain"),
senderAvatar = AvatarData("@senderId:domain", "sender", size = AvatarSize.TimelineSender),
senderAvatar = AvatarData("@senderId:domain", USER_NAME_SENDER, size = AvatarSize.TimelineSender),
content = content,
reactionsState = timelineItemReactions,
readReceiptState = readReceiptState,
@@ -253,7 +251,7 @@ internal fun aGroupedEvents(
}
internal fun aTimelineRoomInfo(
name: String = "Room name",
name: String = ROOM_NAME,
isDm: Boolean = false,
userHasPermissionToSendMessage: Boolean = true,
pinnedEventIds: List<EventId> = emptyList(),
@@ -77,7 +77,7 @@ import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.time.isTalkbackActive
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
import io.element.android.wysiwyg.link.Link
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
@@ -171,7 +171,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,
@@ -213,7 +212,7 @@ fun TimelineView(
onFocusEventRender = ::onFocusEventRender,
)
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,
@@ -47,8 +47,8 @@ import io.element.android.libraries.designsystem.theme.messageFromMeBackground
import io.element.android.libraries.designsystem.theme.messageFromOtherBackground
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
import io.element.android.libraries.ui.utils.graphics.drawInLayer
import io.element.android.libraries.ui.utils.time.isTalkbackActive
private val BUBBLE_RADIUS = 12.dp
private val avatarRadius = AvatarSize.TimelineSender.dp / 2
@@ -26,6 +26,7 @@ import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.R
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.UserId
@@ -162,7 +163,7 @@ internal fun MessageShieldViewPreview() {
MessageShield.AuthenticityNotGuaranteed(false),
forwarder = UserId("@alice:example.com"),
forwarderProfile = ProfileDetails.Ready(
displayName = "Alice",
displayName = USER_NAME_ALICE,
displayNameAmbiguous = false,
avatarUrl = null,
),
@@ -15,9 +15,12 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
@@ -47,9 +50,39 @@ fun TimelineEventTimestampView(
val isMessageEdited = event.content.isEdited()
val isMessageRedacted = event.content.isRedacted()
val tint = if (hasError || hasEncryptionCritical && !isMessageRedacted) ElementTheme.colors.textCriticalPrimary else ElementTheme.colors.textSecondary
val shield = event.messageShield
val isVerifiedUserSendFailure = event.localSendState is LocalEventSendState.Failed.VerifiedUser
val onClickLabel = when {
shield != null -> stringResource(CommonStrings.a11y_view_details)
hasError && isVerifiedUserSendFailure -> stringResource(CommonStrings.action_open_context_menu)
else -> null
}
val clickableModifier = remember(shield, hasError) {
when {
shield != null -> {
Modifier.clickable(
onClickLabel = onClickLabel,
) {
eventSink(TimelineEvent.ShowShieldDialog(shield))
}
}
hasError -> Modifier
.clickable(
enabled = isVerifiedUserSendFailure,
onClickLabel = onClickLabel,
) {
eventSink(TimelineEvent.ComputeVerifiedUserSendFailure(event))
}
else -> Modifier
}
}
Row(
modifier = Modifier
.padding(PaddingValues(start = TimelineEventTimestampViewDefaults.spacing))
// For a better click target, make the corners rounded
.clip(RoundedCornerShape(8.dp))
.then(clickableModifier)
.then(modifier),
verticalAlignment = Alignment.CenterVertically,
) {
@@ -67,36 +100,22 @@ fun TimelineEventTimestampView(
color = tint,
)
if (hasError) {
val isVerifiedUserSendFailure = event.localSendState is LocalEventSendState.Failed.VerifiedUser
Spacer(modifier = Modifier.width(2.dp))
Icon(
imageVector = CompoundIcons.ErrorSolid(),
contentDescription = stringResource(id = CommonStrings.common_sending_failed),
tint = tint,
modifier = Modifier
.size(15.dp, 18.dp)
.clickable(
enabled = isVerifiedUserSendFailure,
onClickLabel = stringResource(CommonStrings.action_open_context_menu),
) {
eventSink(TimelineEvent.ComputeVerifiedUserSendFailure(event))
}
modifier = Modifier.size(15.dp, 18.dp),
)
}
if (!isMessageRedacted) {
event.messageShield?.let { shield ->
shield?.let { shield ->
Spacer(modifier = Modifier.width(2.dp))
Icon(
imageVector = shield.toIcon(),
contentDescription = stringResource(id = CommonStrings.a11y_encryption_details),
modifier = Modifier
.size(15.dp)
.clickable(
onClickLabel = stringResource(CommonStrings.a11y_view_details),
) {
eventSink(TimelineEvent.ShowShieldDialog(shield))
},
modifier = Modifier.size(15.dp),
tint = shield.toIconColor(),
)
Spacer(modifier = Modifier.width(4.dp))
@@ -8,6 +8,7 @@
package io.element.android.features.messages.impl.timeline.components
import androidx.annotation.StringRes
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
@@ -22,17 +23,23 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemReadReceipts
import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo
import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
import io.element.android.features.messages.impl.timeline.components.receipt.aReadReceiptData
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.RtcNotificationState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@@ -42,80 +49,129 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable
internal fun TimelineItemCallNotifyView(
timelineRoomInfo: TimelineRoomInfo,
event: TimelineItem.Event,
content: TimelineItemRtcNotificationContent,
isLastOutgoingMessage: Boolean,
onLongClick: (TimelineItem.Event) -> Unit,
onReadReceiptsClick: (TimelineItem.Event) -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.fillMaxWidth()
.border(1.dp, ElementTheme.colors.borderInteractiveSecondary, RoundedCornerShape(8.dp))
.combinedClickable(
enabled = true,
onClick = {},
onLongClick = { onLongClick(event) },
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
Column(modifier = modifier) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, top = 16.dp)
.border(1.dp, ElementTheme.colors.borderInteractiveSecondary, RoundedCornerShape(8.dp))
.combinedClickable(
enabled = true,
onClick = {},
onLongClick = { onLongClick(event) },
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
)
.onKeyboardContextMenuAction { onLongClick(event) }
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
modifier = Modifier.size(20.sp.toDp()),
imageVector = getIcon(timelineRoomInfo, content),
contentDescription = null,
tint = ElementTheme.colors.iconSecondary,
)
.onKeyboardContextMenuAction { onLongClick(event) }
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Avatar(
avatarData = event.senderAvatar,
avatarType = AvatarType.User,
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = event.safeSenderName,
style = ElementTheme.typography.fontBodyLgMedium,
modifier = Modifier.weight(1f),
text = stringResource(getTextRes(timelineRoomInfo, content)),
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = event.sentTime,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
modifier = Modifier.size(20.sp.toDp()),
imageVector =
if (content.callIntent == CallIntent.AUDIO) CompoundIcons.VoiceCallSolid() else CompoundIcons.VideoCallSolid(),
contentDescription = null,
tint = ElementTheme.colors.iconSecondary,
)
Text(
text = stringResource(CommonStrings.common_call_started),
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Text(
text = event.sentTime,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
TimelineItemReadReceiptView(
state = ReadReceiptViewState(
sendState = event.localSendState,
isLastOutgoingMessage = isLastOutgoingMessage,
receipts = event.readReceiptState.receipts,
),
onReadReceiptsClick = { onReadReceiptsClick(event) },
modifier = Modifier.padding(top = 4.dp),
)
}
}
@StringRes
private fun getTextRes(
timelineRoomInfo: TimelineRoomInfo,
content: TimelineItemRtcNotificationContent
): Int = if (timelineRoomInfo.isDm) {
when (content.state) {
is RtcNotificationState.Declined -> {
if (content.state.byMe) CommonStrings.common_call_you_declined else CommonStrings.common_call_declined
}
RtcNotificationState.Started -> CommonStrings.common_call_started
}
} else {
// In Rooms, do not show declined info.
CommonStrings.common_call_started
}
@Composable
private fun getIcon(
timelineRoomInfo: TimelineRoomInfo,
content: TimelineItemRtcNotificationContent
): ImageVector {
val showAsDeclined = timelineRoomInfo.isDm && content.state is RtcNotificationState.Declined
val icon = if (showAsDeclined) {
if (content.callIntent == CallIntent.AUDIO) CompoundIcons.VoiceCallDeclinedSolid() else CompoundIcons.VideoCallDeclinedSolid()
} else {
if (content.callIntent == CallIntent.AUDIO) CompoundIcons.VoiceCallSolid() else CompoundIcons.VideoCallSolid()
}
return icon
}
@PreviewsDayNight
@Composable
internal fun TimelineItemCallNotifyViewPreview() = ElementPreview {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
listOf(
TimelineItemRtcNotificationContent(CallIntent.AUDIO),
TimelineItemRtcNotificationContent(CallIntent.VIDEO),
).forEach { content ->
TimelineItemCallNotifyView(
event = aTimelineItemEvent(content = content),
content = content,
onLongClick = {},
)
val readReceiptState = mutableListOf(
aTimelineItemReadReceipts(
receipts = List(3) { aReadReceiptData(it) },
)
)
Column(modifier = Modifier.padding(bottom = 16.dp)) {
listOf(false, true).forEach { isDm ->
listOf(CallIntent.AUDIO, CallIntent.VIDEO).forEach { callIntent ->
listOf(
RtcNotificationState.Started,
RtcNotificationState.Declined(byMe = false),
RtcNotificationState.Declined(byMe = true),
).forEach { state ->
val content = TimelineItemRtcNotificationContent(callIntent, state)
TimelineItemCallNotifyView(
timelineRoomInfo = aTimelineRoomInfo(isDm = isDm),
event = aTimelineItemEvent(
content = content,
// Only display read receipts for the first item
readReceiptState = readReceiptState.removeFirstOrNull() ?: aTimelineItemReadReceipts(),
),
content = content,
isLastOutgoingMessage = false,
onLongClick = {},
onReadReceiptsClick = {},
)
}
}
}
}
}
@@ -92,6 +92,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.modifiers.niceClickable
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
import io.element.android.libraries.designsystem.swipe.SwipeableActionsState
import io.element.android.libraries.designsystem.swipe.rememberSwipeableActionsState
import io.element.android.libraries.designsystem.text.toPx
@@ -120,7 +121,7 @@ import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonPlurals
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.time.isTalkbackActive
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
import io.element.android.wysiwyg.link.Link
import kotlinx.coroutines.launch
import kotlin.math.abs
@@ -141,7 +142,6 @@ fun TimelineItemEventRow(
timelineMode: Timeline.Mode,
timelineRoomInfo: TimelineRoomInfo,
timelineProtectionState: TimelineProtectionState,
renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
displayThreadSummaries: Boolean,
onEventClick: () -> Unit,
@@ -295,7 +295,6 @@ fun TimelineItemEventRow(
isLastOutgoingMessage = isLastOutgoingMessage,
receipts = event.readReceiptState.receipts,
),
renderReadReceipts = renderReadReceipts,
onReadReceiptsClick = { onReadReceiptClick(event) },
modifier = Modifier.padding(top = 4.dp)
)
@@ -782,7 +781,7 @@ private fun MessageEventBubbleContent(
val content = content.ensureActiveLiveLocation()
val shouldHide = content.mode is TimelineItemLocationContent.Mode.Live &&
content.mode.isActive &&
content.mode.canStop
content.mode.isOwnUser
if (shouldHide) TimestampPosition.Hidden else TimestampPosition.Overlay
}
is TimelineItemPollContent -> TimestampPosition.Below
@@ -863,7 +862,7 @@ internal fun TimelineItemEventRowWithThreadSummaryPreview() = ElementPreview {
),
senderId = UserId("@user:id"),
senderProfile = ProfileDetails.Ready(
displayName = "Alice",
displayName = USER_NAME_ALICE,
avatarUrl = null,
displayNameAmbiguous = false,
),
@@ -898,7 +897,7 @@ internal fun ThreadSummaryViewPreview() {
),
senderId = UserId("@user:id"),
senderProfile = ProfileDetails.Ready(
displayName = "Alice",
displayName = USER_NAME_ALICE,
avatarUrl = null,
displayNameAmbiguous = true,
),
@@ -16,6 +16,8 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItemGrou
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
import io.element.android.libraries.designsystem.preview.USER_NAME_BOB
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
import io.element.android.libraries.matrix.api.timeline.item.event.UtdCause
@@ -25,7 +27,7 @@ internal fun TimelineItemEventRowUtdPreview() = ElementPreview {
Column {
ATimelineItemEventRow(
event = aTimelineItemEvent(
senderDisplayName = "Alice",
senderDisplayName = USER_NAME_ALICE,
isMine = false,
content = TimelineItemEncryptedContent(
data = UnableToDecryptContent.Data.MegolmV1AesSha2(
@@ -39,7 +41,7 @@ internal fun TimelineItemEventRowUtdPreview() = ElementPreview {
)
ATimelineItemEventRow(
event = aTimelineItemEvent(
senderDisplayName = "Bob",
senderDisplayName = USER_NAME_BOB,
isMine = false,
content = TimelineItemEncryptedContent(
data = UnableToDecryptContent.Data.MegolmV1AesSha2(
@@ -54,7 +56,7 @@ internal fun TimelineItemEventRowUtdPreview() = ElementPreview {
ATimelineItemEventRow(
event = aTimelineItemEvent(
senderDisplayName = "Bob",
senderDisplayName = USER_NAME_BOB,
isMine = false,
content = TimelineItemEncryptedContent(
data = UnableToDecryptContent.Data.MegolmV1AesSha2(
@@ -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,
)
}
@@ -34,7 +34,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.utils.time.isTalkbackActive
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
import io.element.android.wysiwyg.link.Link
@Composable
@@ -43,7 +43,6 @@ fun TimelineItemGroupedEventsRow(
timelineMode: Timeline.Mode,
timelineRoomInfo: TimelineRoomInfo,
timelineProtectionState: TimelineProtectionState,
renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
focusedEventId: EventId?,
displayThreadSummaries: Boolean,
@@ -63,7 +62,7 @@ fun TimelineItemGroupedEventsRow(
{ event, contentModifier, onContentLayoutChange ->
TimelineItemEventContentView(
content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId),
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine),
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
@@ -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,
@@ -136,7 +133,7 @@ private fun TimelineItemGroupedEventsRowContent(
{ event, contentModifier, onContentLayoutChange ->
TimelineItemEventContentView(
content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId),
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine),
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
@@ -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 = {},
@@ -47,7 +47,7 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.time.isTalkbackActive
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
import io.element.android.wysiwyg.link.Link
import kotlin.time.DurationUnit
@@ -56,7 +56,6 @@ internal fun TimelineItemRow(
timelineItem: TimelineItem,
timelineMode: Timeline.Mode,
timelineRoomInfo: TimelineRoomInfo,
renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
timelineProtectionState: TimelineProtectionState,
focusedEventId: EventId?,
@@ -78,7 +77,7 @@ internal fun TimelineItemRow(
{ event, contentModifier, onContentLayoutChange ->
TimelineItemEventContentView(
content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId),
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine),
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onContentClick = { onContentClick(event) },
onLongClick = { onLongClick(event) },
@@ -114,7 +113,6 @@ internal fun TimelineItemRow(
is TimelineItemStateContent, is TimelineItemLegacyCallInviteContent -> {
TimelineItemStateEventRow(
event = timelineItem,
renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
onClick = { onContentClick(timelineItem) },
onReadReceiptsClick = onReadReceiptClick,
@@ -124,10 +122,12 @@ internal fun TimelineItemRow(
}
is TimelineItemRtcNotificationContent -> {
TimelineItemCallNotifyView(
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp),
timelineRoomInfo = timelineRoomInfo,
event = timelineItem,
content = timelineItem.content,
isLastOutgoingMessage = isLastOutgoingMessage,
onLongClick = onLongClick,
onReadReceiptsClick = onReadReceiptClick,
)
}
else -> {
@@ -164,7 +164,6 @@ internal fun TimelineItemRow(
event = timelineItem,
timelineMode = timelineMode,
timelineRoomInfo = timelineRoomInfo,
renderReadReceipts = renderReadReceipts,
timelineProtectionState = timelineProtectionState,
isLastOutgoingMessage = isLastOutgoingMessage,
displayThreadSummaries = displayThreadSummaries,
@@ -193,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 = {},
@@ -54,10 +54,11 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.time.isTalkbackActive
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
import io.element.android.wysiwyg.compose.EditorStyledText
import io.element.android.wysiwyg.link.Link
private const val TALL_IMAGE_RATIO_DIVISOR = 3
@Composable
fun TimelineItemImageView(
content: TimelineItemImageContent,
@@ -79,7 +80,7 @@ fun TimelineItemImageView(
Modifier
}
TimelineItemAspectRatioBox(
modifier = containerModifier.blurHashBackground(content.blurhash, alpha = 0.9f),
modifier = containerModifier.blurHashBackground(content.blurhash, alpha = 0.9f).align(Alignment.CenterHorizontally),
aspectRatio = coerceRatioWhenHidingContent(content.aspectRatio, hideMediaContent),
) {
ProtectedView(
@@ -123,7 +124,14 @@ fun TimelineItemImageView(
LocalContentColor provides ElementTheme.colors.textPrimary,
LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular
) {
val aspectRatio = content.aspectRatio ?: DEFAULT_ASPECT_RATIO
val width = content.width ?: 0
val height = content.height ?: 0
// if image is narrow and tall use DEFAULT_ASPECT_RATIO
val aspectRatio = if (width < height / TALL_IMAGE_RATIO_DIVISOR) {
DEFAULT_ASPECT_RATIO
} else {
content.aspectRatio ?: DEFAULT_ASPECT_RATIO
}
EditorStyledText(
modifier = Modifier
.padding(horizontal = 4.dp) // This is (12.dp - 8.dp) contentPadding from CommonLayout
@@ -200,3 +208,38 @@ internal fun TimelineImageWithCaptionRowPreview() = ElementPreview {
)
}
}
@PreviewsDayNight
@Composable
internal fun ATimelineItemEventRowPreview() = ElementPreview {
Column {
sequenceOf(false, true).forEach { isMine ->
ATimelineItemEventRow(
event = aTimelineItemEvent(
isMine = isMine,
content = aTimelineItemImageContent(
filename = "image.jpg",
caption = "A long caption that may wrap into several lines",
width = 80,
height = 300,
aspectRatio = 80f / 300f,
),
groupPosition = TimelineItemGroupPosition.Last,
),
)
}
ATimelineItemEventRow(
event = aTimelineItemEvent(
isMine = false,
content = aTimelineItemImageContent(
filename = "image.jpg",
caption = "Narrow image with null aspectRatio",
width = 80,
height = 300,
aspectRatio = null,
),
groupPosition = TimelineItemGroupPosition.Last,
),
)
}
}
@@ -13,14 +13,12 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -77,13 +75,14 @@ private fun LiveLocationOverlay(
Row(
modifier = modifier
.fillMaxWidth()
.background(ElementTheme.colors.bgCanvasDefault.copy(alpha = 0.9f))
.padding(horizontal = 8.dp, vertical = 8.dp),
.background(ElementTheme.colors.bgCanvasDefault.copy(alpha = 0.9f)),
verticalAlignment = Alignment.CenterVertically,
) {
val iconShape = RoundedCornerShape(8.dp)
Box(
modifier = Modifier
// Ensure this Box uses same spacings than the Stop IconButton.
.minimumInteractiveComponentSize()
.size(32.dp)
.border(
width = 1.dp,
@@ -120,7 +119,6 @@ private fun LiveLocationOverlay(
)
}
}
Spacer(Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = if (mode.isActive) {
@@ -140,13 +138,16 @@ private fun LiveLocationOverlay(
}
}
if (mode.isActive && mode.canStop) {
if (mode.canStopSharing) {
IconButton(
onClick = onStopClick,
colors = IconButtonDefaults.iconButtonColors(
containerColor = ElementTheme.colors.bgCriticalPrimary,
contentColor = ElementTheme.colors.iconOnSolidPrimary,
)
),
modifier = Modifier
.minimumInteractiveComponentSize()
.size(30.dp)
) {
Icon(
imageVector = CompoundIcons.Stop(),
@@ -64,7 +64,7 @@ import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_WIDTH
import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.time.isTalkbackActive
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
import io.element.android.wysiwyg.compose.EditorStyledText
import io.element.android.wysiwyg.link.Link
@@ -52,7 +52,7 @@ 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.Text
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.time.isTalkbackActive
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
import io.element.android.libraries.voiceplayer.api.VoiceMessageEvent
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
import io.element.android.libraries.voiceplayer.api.VoiceMessageStateProvider
@@ -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 = {},
)
}
@@ -24,6 +24,7 @@ import io.element.android.features.messages.impl.R
import io.element.android.libraries.designsystem.atomic.molecules.ComposerAlertMolecule
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ROOM_NAME
import io.element.android.libraries.designsystem.text.toAnnotatedString
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.allBooleans
@@ -86,13 +87,13 @@ internal fun TimelineItemRoomBeginningViewPreview() = ElementPreview {
)
TimelineItemRoomBeginningView(
predecessorRoom = null,
roomName = "Room Name",
roomName = ROOM_NAME,
isDm = isDm,
onPredecessorRoomClick = {},
)
TimelineItemRoomBeginningView(
predecessorRoom = PredecessorRoom(RoomId("!roomId:matrix.org")),
roomName = "Room Name",
roomName = ROOM_NAME,
isDm = isDm,
onPredecessorRoomClick = {},
)
@@ -8,7 +8,9 @@
package io.element.android.features.messages.impl.timeline.di
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.model.event.ensureActiveLiveLocation
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
import io.element.android.libraries.voiceplayer.api.aVoiceMessageState
@@ -18,6 +20,12 @@ import io.element.android.libraries.voiceplayer.api.aVoiceMessageState
*/
fun aFakeTimelineItemPresenterFactories() = TimelineItemPresenterFactories(
mapOf(
Pair(
TimelineItemLocationContent::class,
TimelineItemPresenterFactory<TimelineItemLocationContent, TimelineItemLocationContent> { content ->
Presenter { content.ensureActiveLiveLocation() }
},
),
Pair(
TimelineItemVoiceContent::class,
TimelineItemPresenterFactory<TimelineItemVoiceContent, VoiceMessageState> { Presenter { aVoiceMessageState() } },
@@ -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
}
@@ -10,6 +10,7 @@ package io.element.android.features.messages.impl.timeline.factories.event
import dev.zacsweers.metro.Inject
import io.element.android.features.location.api.Location
import io.element.android.features.messages.impl.timeline.model.event.RtcNotificationState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
@@ -104,7 +105,12 @@ class TimelineItemContentFactory(
is PollContent -> pollFactory.create(eventId, isEditable, isOutgoing, itemContent)
is UnableToDecryptContent -> utdFactory.create(itemContent)
is CallNotifyContent -> TimelineItemRtcNotificationContent(
itemContent.callIntent
callIntent = itemContent.callIntent,
state = if (itemContent.declinedBy.isEmpty()) {
RtcNotificationState.Started
} else {
RtcNotificationState.Declined(itemContent.declinedBy.any { it == sessionId })
}
)
is UnknownContent -> TimelineItemUnknownContent
is LiveLocationContent -> {
@@ -127,6 +133,7 @@ class TimelineItemContentFactory(
isActive = itemContent.isLive,
endsAt = stringProvider.getString(CommonStrings.common_ends_at, endsAt),
endTimestamp = itemContent.endTimestamp,
isOwnUser = sessionId == sender
),
)
}
@@ -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(
@@ -27,7 +27,7 @@ class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEv
aTimelineItemAudioContent(),
aTimelineItemAudioContent("An even bigger bigger bigger bigger bigger bigger bigger sound name which doesn't fit .mp3"),
aTimelineItemVoiceContent(),
aTimelineItemLocationContent(),
aTimelineItemLocationContent(mode = aStaticLocationMode()),
aTimelineItemPollContent(),
aTimelineItemNoticeContent(),
aTimelineItemRedactedContent(),
@@ -36,7 +36,7 @@ class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEv
aTimelineItemTextContent().copy(isEdited = true),
aTimelineItemTextContent(body = AN_EMOJI_ONLY_TEXT),
aTimelineItemLocationContent(
mode = TimelineItemLocationContent.Mode.Live(isActive = true, endsAt = "Ends at 12:34", endTimestamp = 0L, lastKnownLocation = null)
mode = aLiveLocationMode(isActive = true, endsAt = "Ends at 12:34", endTimestamp = 0L, lastKnownLocation = null)
),
)
}
@@ -28,6 +28,8 @@ fun aTimelineItemImageContent(
blurhash: String? = A_BLUR_HASH,
filename: String = "A picture.jpg",
caption: String? = null,
width: Int? = null,
height: Int? = 300,
) = TimelineItemImageContent(
filename = filename,
fileSize = 4 * 1024 * 1024L,
@@ -38,8 +40,8 @@ fun aTimelineItemImageContent(
thumbnailSource = null,
mimeType = MimeTypes.IMAGE_JPEG,
blurhash = blurhash,
width = null,
height = 300,
width = width,
height = height,
thumbnailWidth = null,
thumbnailHeight = 150,
aspectRatio = aspectRatio,
@@ -9,7 +9,9 @@
package io.element.android.features.messages.impl.timeline.model.event
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.rememberUpdatedState
import io.element.android.features.location.api.Location
import io.element.android.libraries.designsystem.components.PinVariant
import io.element.android.libraries.designsystem.components.avatar.AvatarData
@@ -72,9 +74,10 @@ data class TimelineItemLocationContent(
val isActive: Boolean,
val endsAt: String,
val endTimestamp: Long,
val canStop: Boolean = false,
val isOwnUser: Boolean,
) : Mode {
val isLoading = lastKnownLocation == null && isActive
val canStopSharing = isActive && isOwnUser
}
}
@@ -102,8 +105,9 @@ private fun rememberIsLiveLocationActive(
mode: TimelineItemLocationContent.Mode.Live,
currentTimeMillis: () -> Long,
): Boolean {
val updatedCurrentTimeMillis by rememberUpdatedState(currentTimeMillis)
fun TimelineItemLocationContent.Mode.Live.isActive(): Boolean {
return isActive && endTimestamp > currentTimeMillis()
return isActive && endTimestamp > updatedCurrentTimeMillis()
}
return produceState(
initialValue = mode.isActive(),
@@ -111,7 +115,7 @@ private fun rememberIsLiveLocationActive(
key2 = mode.isActive,
) {
if (mode.isActive) {
val remainingMillis = mode.endTimestamp - currentTimeMillis()
val remainingMillis = mode.endTimestamp - updatedCurrentTimeMillis()
delay(remainingMillis)
}
value = false
@@ -17,48 +17,44 @@ import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsRead
open class TimelineItemLocationContentProvider : PreviewParameterProvider<TimelineItemLocationContent> {
override val values: Sequence<TimelineItemLocationContent>
get() = sequenceOf(
aTimelineItemLocationContent(),
aTimelineItemLocationContent(
mode = TimelineItemLocationContent.Mode.Live(
isActive = true,
endsAt = "Ends at 12:34",
endTimestamp = 0L,
canStop = true,
lastKnownLocation = aLocation()
),
mode = aStaticLocationMode()
),
aTimelineItemLocationContent(
mode = TimelineItemLocationContent.Mode.Live(
isActive = true,
endsAt = "Ends at 12:34",
endTimestamp = 0L,
lastKnownLocation = aLocation()
),
mode = aLiveLocationMode(isActive = true)
),
aTimelineItemLocationContent(
mode = TimelineItemLocationContent.Mode.Live(
isActive = true,
endsAt = "Ends at 12:34",
endTimestamp = 0L,
lastKnownLocation = null
),
mode = aLiveLocationMode(isActive = true, lastKnownLocation = null)
),
aTimelineItemLocationContent(
mode = TimelineItemLocationContent.Mode.Live(
isActive = false,
endsAt = "",
endTimestamp = 0L,
lastKnownLocation = aLocation()
),
mode = aLiveLocationMode(isActive = true, isOwnUser = false)
),
aTimelineItemLocationContent(
mode = aLiveLocationMode(isActive = false)
),
)
}
fun aLiveLocationMode(
isActive: Boolean,
isOwnUser: Boolean = true,
lastKnownLocation: Location? = aLocation(),
endsAt: String = "Ends at 12:34",
endTimestamp: Long = 0L,
): TimelineItemLocationContent.Mode = TimelineItemLocationContent.Mode.Live(
isActive = isActive,
endsAt = endsAt,
endTimestamp = endTimestamp,
isOwnUser = isOwnUser,
lastKnownLocation = lastKnownLocation
)
fun aStaticLocationMode(location: Location = aLocation()) = TimelineItemLocationContent.Mode.Static(location)
fun aTimelineItemLocationContent(
senderId: UserId = UserId("@sender:matrix.org"),
senderProfile: ProfileDetails = aProfileDetailsReady(),
description: String? = null,
mode: TimelineItemLocationContent.Mode = TimelineItemLocationContent.Mode.Static(aLocation()),
mode: TimelineItemLocationContent.Mode,
) = TimelineItemLocationContent(
senderId = senderId,
senderProfile = senderProfile,
@@ -9,7 +9,19 @@
package io.element.android.features.messages.impl.timeline.model.event
import io.element.android.libraries.matrix.api.notification.CallIntent
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
class TimelineItemRtcNotificationContent(val callIntent: CallIntent) : TimelineItemEventContent {
override val type: String = "org.matrix.msc4075.rtc.notification"
// State of the call, for now only isDeclined but in the future could be missed, active.
sealed interface RtcNotificationState {
/** Some users have declined, byMe indicates if the current user is one of them. */
data class Declined(val byMe: Boolean) : RtcNotificationState
object Started : RtcNotificationState
}
class TimelineItemRtcNotificationContent(
val callIntent: CallIntent,
val state: RtcNotificationState,
) : TimelineItemEventContent {
override val type: String = EventType.RTC_NOTIFICATION
}
@@ -34,26 +34,32 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
*/
fun TimelineItem.mustBeProtected(): Boolean {
return when (this) {
is TimelineItem.Event -> when (content) {
is TimelineItemImageContent,
is TimelineItemVideoContent,
is TimelineItemStickerContent -> true
is TimelineItemAudioContent,
is TimelineItemRtcNotificationContent,
is TimelineItemEncryptedContent,
is TimelineItemFileContent,
TimelineItemLegacyCallInviteContent,
is TimelineItemLocationContent,
is TimelineItemPollContent,
TimelineItemRedactedContent,
is TimelineItemProfileChangeContent,
is TimelineItemRoomMembershipContent,
is TimelineItemStateEventContent,
is TimelineItemEmoteContent,
is TimelineItemNoticeContent,
is TimelineItemTextContent,
TimelineItemUnknownContent,
is TimelineItemVoiceContent -> false
is TimelineItem.Event -> {
if (isMine) {
false
} else {
when (content) {
is TimelineItemImageContent,
is TimelineItemVideoContent,
is TimelineItemStickerContent -> true
is TimelineItemAudioContent,
is TimelineItemRtcNotificationContent,
is TimelineItemEncryptedContent,
is TimelineItemFileContent,
TimelineItemLegacyCallInviteContent,
is TimelineItemLocationContent,
is TimelineItemPollContent,
TimelineItemRedactedContent,
is TimelineItemProfileChangeContent,
is TimelineItemRoomMembershipContent,
is TimelineItemStateEventContent,
is TimelineItemEmoteContent,
is TimelineItemNoticeContent,
is TimelineItemTextContent,
TimelineItemUnknownContent,
is TimelineItemVoiceContent -> false
}
}
}
is TimelineItem.Virtual -> false
is TimelineItem.GroupedEvents -> false
@@ -16,9 +16,13 @@ data class TimelineProtectionState(
val protectionState: ProtectionState,
val eventSink: (TimelineProtectionEvent) -> Unit,
) {
fun hideMediaContent(eventId: EventId?) = when (protectionState) {
is ProtectionState.RenderAll -> false
is ProtectionState.RenderOnly -> eventId !in protectionState.eventIds
fun hideMediaContent(eventId: EventId?, isMine: Boolean = false) = if (isMine) {
false
} else {
when (protectionState) {
is ProtectionState.RenderAll -> false
is ProtectionState.RenderOnly -> eventId !in protectionState.eventIds
}
}
}
@@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
@@ -42,6 +43,7 @@ import io.element.android.libraries.designsystem.components.avatar.anAvatarData
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ROOM_NAME
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
@@ -78,7 +80,8 @@ internal fun MessagesViewTopBar(
Row(
modifier = Modifier
.clip(roundedCornerShape)
.clickable { onRoomDetailsClick() },
.clickable { onRoomDetailsClick() }
.semantics { heading() },
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
@@ -91,9 +94,12 @@ internal fun MessagesViewTopBar(
modifier = titleModifier
)
val iconModifier = Modifier.size(16.dp)
when (dmUserIdentityState) {
IdentityState.Verified -> {
Icon(
modifier = iconModifier,
imageVector = CompoundIcons.Verified(),
tint = ElementTheme.colors.iconSuccessPrimary,
contentDescription = null,
@@ -101,6 +107,7 @@ internal fun MessagesViewTopBar(
}
IdentityState.VerificationViolation -> {
Icon(
modifier = iconModifier,
imageVector = CompoundIcons.ErrorSolid(),
tint = ElementTheme.colors.iconCriticalPrimary,
contentDescription = null,
@@ -112,11 +119,13 @@ internal fun MessagesViewTopBar(
when (sharedHistoryIcon) {
SharedHistoryIcon.NONE -> Unit
SharedHistoryIcon.SHARED -> Icon(
modifier = iconModifier,
imageVector = CompoundIcons.History(),
tint = ElementTheme.colors.iconInfoPrimary,
contentDescription = stringResource(CommonStrings.common_shared_history),
)
SharedHistoryIcon.WORLD_READABLE -> Icon(
modifier = iconModifier,
imageVector = CompoundIcons.UserProfileSolid(),
tint = ElementTheme.colors.iconInfoPrimary,
contentDescription = stringResource(CommonStrings.common_world_readable_history),
@@ -150,10 +159,7 @@ private fun RoomAvatarAndNameRow(
)
Text(
modifier = Modifier
.padding(horizontal = 8.dp)
.semantics {
heading()
},
.padding(start = 8.dp),
text = roomName ?: stringResource(CommonStrings.common_no_room_name),
style = ElementTheme.typography.fontBodyLgMedium,
fontStyle = FontStyle.Italic.takeIf { roomName == null },
@@ -168,9 +174,9 @@ private fun RoomAvatarAndNameRow(
internal fun MessagesViewTopBarPreview() = ElementPreview {
@Composable
fun AMessagesViewTopBar(
roomName: String? = "Room name",
roomName: String? = ROOM_NAME,
roomAvatar: AvatarData = anAvatarData(
name = "Room name",
name = ROOM_NAME,
size = AvatarSize.TimelineRoom,
),
isTombstoned: Boolean = false,
@@ -17,8 +17,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -31,6 +32,7 @@ import io.element.android.libraries.designsystem.components.avatar.anAvatarData
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ROOM_NAME
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
@@ -57,7 +59,18 @@ internal fun ThreadTopBar(
BackButton(onClick = onBackClick)
},
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
val name = roomName ?: stringResource(CommonStrings.common_no_room_name)
val description = stringResource(
CommonStrings.a11y_thread_in_room,
name,
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clearAndSetSemantics {
heading()
contentDescription = description
},
) {
Avatar(
avatarData = roomAvatarData,
avatarType = AvatarType.Room(
@@ -68,17 +81,14 @@ internal fun ThreadTopBar(
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp)
.semantics {
heading()
},
.padding(horizontal = 8.dp),
) {
Text(
text = stringResource(CommonStrings.common_thread),
style = ElementTheme.typography.fontBodyLgMedium,
)
Text(
text = roomName ?: stringResource(CommonStrings.common_no_room_name),
text = name,
style = ElementTheme.typography.fontBodySmRegular,
fontStyle = FontStyle.Italic.takeIf { roomName == null },
color = ElementTheme.colors.textSecondary,
@@ -96,9 +106,9 @@ internal fun ThreadTopBar(
internal fun ThreadTopBarPreview() = ElementPreview {
@Composable
fun AThreadTopBar(
roomName: String? = "Room name",
roomName: String? = ROOM_NAME,
roomAvatarData: AvatarData = anAvatarData(
name = "Room name",
name = ROOM_NAME,
size = AvatarSize.TimelineRoom,
),
isTombstoned: Boolean = false,
@@ -123,7 +133,7 @@ internal fun ThreadTopBarPreview() = ElementPreview {
HorizontalDivider()
AThreadTopBar(
roomAvatarData = anAvatarData(
name = "Room name",
name = ROOM_NAME,
url = "https://some-avatar.jpg",
size = AvatarSize.TimelineRoom,
),
@@ -9,6 +9,11 @@
package io.element.android.features.messages.impl.typing
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
import io.element.android.libraries.designsystem.preview.USER_NAME_BOB
import io.element.android.libraries.designsystem.preview.USER_NAME_CHARLIE
import io.element.android.libraries.designsystem.preview.USER_NAME_DAVID
import io.element.android.libraries.designsystem.preview.USER_NAME_EVE
import kotlinx.collections.immutable.toImmutableList
class TypingNotificationStateProvider : PreviewParameterProvider<TypingNotificationState> {
@@ -22,7 +27,7 @@ class TypingNotificationStateProvider : PreviewParameterProvider<TypingNotificat
),
aTypingNotificationState(
typingMembers = listOf(
aTypingRoomMember(disambiguatedDisplayName = "Alice"),
aTypingRoomMember(disambiguatedDisplayName = USER_NAME_ALICE),
),
),
aTypingNotificationState(
@@ -32,24 +37,24 @@ class TypingNotificationStateProvider : PreviewParameterProvider<TypingNotificat
),
aTypingNotificationState(
typingMembers = listOf(
aTypingRoomMember(disambiguatedDisplayName = "Alice"),
aTypingRoomMember(disambiguatedDisplayName = "Bob"),
aTypingRoomMember(disambiguatedDisplayName = USER_NAME_ALICE),
aTypingRoomMember(disambiguatedDisplayName = USER_NAME_BOB),
),
),
aTypingNotificationState(
typingMembers = listOf(
aTypingRoomMember(disambiguatedDisplayName = "Alice"),
aTypingRoomMember(disambiguatedDisplayName = "Bob"),
aTypingRoomMember(disambiguatedDisplayName = "Charlie"),
aTypingRoomMember(disambiguatedDisplayName = USER_NAME_ALICE),
aTypingRoomMember(disambiguatedDisplayName = USER_NAME_BOB),
aTypingRoomMember(disambiguatedDisplayName = USER_NAME_CHARLIE),
),
),
aTypingNotificationState(
typingMembers = listOf(
aTypingRoomMember(disambiguatedDisplayName = "Alice"),
aTypingRoomMember(disambiguatedDisplayName = "Bob"),
aTypingRoomMember(disambiguatedDisplayName = "Charlie"),
aTypingRoomMember(disambiguatedDisplayName = "Dan"),
aTypingRoomMember(disambiguatedDisplayName = "Eve"),
aTypingRoomMember(disambiguatedDisplayName = USER_NAME_ALICE),
aTypingRoomMember(disambiguatedDisplayName = USER_NAME_BOB),
aTypingRoomMember(disambiguatedDisplayName = USER_NAME_CHARLIE),
aTypingRoomMember(disambiguatedDisplayName = USER_NAME_DAVID),
aTypingRoomMember(disambiguatedDisplayName = USER_NAME_EVE),
),
),
aTypingNotificationState(
@@ -10,6 +10,7 @@ package io.element.android.features.messages.impl.utils.messagesummary
import android.content.Context
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.messages.impl.timeline.model.event.RtcNotificationState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
@@ -56,7 +57,16 @@ class DefaultMessageSummaryFormatter(
is TimelineItemFileContent -> context.getString(CommonStrings.common_file)
is TimelineItemAudioContent -> context.getString(CommonStrings.common_audio)
is TimelineItemLegacyCallInviteContent -> context.getString(CommonStrings.common_unsupported_call)
is TimelineItemRtcNotificationContent -> context.getString(CommonStrings.common_call_started)
is TimelineItemRtcNotificationContent -> when (content.state) {
is RtcNotificationState.Declined -> {
if (content.state.byMe) {
context.getString(CommonStrings.common_call_you_declined)
} else {
context.getString(CommonStrings.common_call_declined)
}
}
RtcNotificationState.Started -> context.getString(CommonStrings.common_call_started)
}
}
// Truncate the message to a safe length to avoid crashes in Compose
.toSafeLength()
@@ -16,6 +16,8 @@
<string name="emoji_picker_category_symbols">"Сімвалы"</string>
<string name="screen_media_upload_preview_error_failed_processing">"Не атрымалася апрацаваць медыяфайл для загрузкі, паспрабуйце яшчэ раз."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Не атрымалася загрузіць медыяфайлы, паспрабуйце яшчэ раз."</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>
@@ -25,7 +27,7 @@
<string name="screen_room_attachment_source_camera_video">"Запісаць відэа"</string>
<string name="screen_room_attachment_source_files">"Далучэнне"</string>
<string name="screen_room_attachment_source_gallery">"Бібліятэка фота &amp; відэа"</string>
<string name="screen_room_attachment_source_location">"Месцазнаходжанне"</string>
<string name="screen_room_attachment_source_location">"Абагуліць месцазнаходжанне"</string>
<string name="screen_room_attachment_source_poll">"Апытанне"</string>
<string name="screen_room_attachment_text_formatting">"Фармаціраванне тэксту"</string>
<string name="screen_room_encrypted_history_banner">"Гісторыя паведамленняў зараз недаступна."</string>
@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crypto_event_authenticity_mismatched_sender">"El remitent de l\'esdeveniment no coincideix amb el propietari del dispositiu que l\'ha enviat."</string>
<string name="crypto_event_authenticity_not_guaranteed">"L\'autenticitat d\'aquest missatge xifrat en aquest dispositiu no es pot garantir."</string>
<string name="crypto_event_authenticity_previously_verified">"Xifrat per un usuari prèviament verificat."</string>
<string name="crypto_event_authenticity_sent_in_clear">"No xifrat."</string>
<string name="crypto_event_authenticity_unknown_device">"Xifrat per un dispositiu desconegut o eliminat."</string>
<string name="crypto_event_authenticity_unsigned_device">"Xifrat per un dispositiu no verificat pel seu propietari."</string>
<string name="crypto_event_authenticity_unverified_identity">"Xifrat per un usuari no verificat."</string>
<string name="emoji_picker_category_activity">"Activitats"</string>
<string name="emoji_picker_category_flags">"Banderes"</string>
<string name="emoji_picker_category_foods">"Menjar &amp; begudes"</string>
<string name="emoji_picker_category_nature">"Animals i naturalesa"</string>
<string name="emoji_picker_category_objects">"Objectes"</string>
<string name="emoji_picker_category_people">"Cares i persones"</string>
<string name="emoji_picker_category_places">"Viatges i llocs"</string>
<string name="emoji_picker_category_recent">"Emoticones recents"</string>
<string name="emoji_picker_category_symbols">"Símbols"</string>
<string name="screen_media_upload_preview_caption_warning">"És possible que les llegendes no siguin visibles pels que utilitzin aplicacions antigues."</string>
<string name="screen_media_upload_preview_error_failed_processing">"No s\'ha pogut processar el contingut que s\'havia de pujar. Torna-ho a provar."</string>
<string name="screen_media_upload_preview_error_failed_sending">"No s\'ha pogut pujar el contingut. Torna-ho a provar."</string>
<string name="screen_report_content_block_user">"Bloqueja usuari"</string>
<string name="screen_report_content_block_user_hint">"Marca si vols ocultar tots els missatges actuals i futurs d\'aquest usuari"</string>
<string name="screen_report_content_explanation">"Aquest missatge s\'enviarà a l\'administrador del teu servidor. No podrà llegir cap missatge xifrat."</string>
<string name="screen_report_content_hint">"Motiu de la denúncia del contingut"</string>
<string name="screen_room_attachment_source_camera">"Càmera"</string>
<string name="screen_room_attachment_source_camera_photo">"Fes una foto"</string>
<string name="screen_room_attachment_source_camera_video">"Enregistra vídeo"</string>
<string name="screen_room_attachment_source_files">"Fitxer adjunt"</string>
<string name="screen_room_attachment_source_gallery">"Galeria de fotos i vídeos"</string>
<string name="screen_room_attachment_source_location">"Ubicació"</string>
<string name="screen_room_attachment_source_poll">"Votació"</string>
<string name="screen_room_attachment_text_formatting">"Format de text"</string>
<string name="screen_room_encrypted_history_banner">"L\'historial de missatges no està disponible actualment."</string>
<string name="screen_room_encrypted_history_banner_unverified">"L\'historial de missatges no està disponible en aquesta sala. Verifica aquest dispositiu per veure l\'historial de missatges."</string>
<string name="screen_room_invite_again_alert_message">"Vols tornar-los a convidar-los?"</string>
<string name="screen_room_invite_again_alert_title">"No hi ha ningú més al xat"</string>
<string name="screen_room_mentions_at_room_subtitle">"Notifica tota la sala"</string>
<string name="screen_room_mentions_at_room_title">"Tothom"</string>
<string name="screen_room_retry_send_menu_send_again_action">"Torna a enviar"</string>
<string name="screen_room_retry_send_menu_title">"No s\'han pogut enviar els missatges"</string>
<string name="screen_room_timeline_add_reaction">"Afegeix reacció"</string>
<string name="screen_room_timeline_beginning_of_room">"Aquest és el principi de %1$s."</string>
<string name="screen_room_timeline_beginning_of_room_no_name">"Principi d\'aquesta conversa."</string>
<string name="screen_room_timeline_legacy_call">"Trucada no compatible. Comprova si la persona que truca està utilitzant la nova aplicació Element X."</string>
<string name="screen_room_timeline_less_reactions">"Mostra\'n menys"</string>
<string name="screen_room_timeline_message_copied">"Missatge copiat"</string>
<string name="screen_room_timeline_no_permission_to_post">"No tens permís per enviar res en aquesta sala"</string>
<string name="screen_room_timeline_reactions_show_less">"Mostra\'n menys"</string>
<string name="screen_room_timeline_reactions_show_more">"Mostra\'n més"</string>
<string name="screen_room_timeline_read_marker_title">"Nous"</string>
<plurals name="screen_room_timeline_state_changes">
<item quantity="one">"%1$d canvi a la sala"</item>
<item quantity="other">"%1$d canvis a la sala"</item>
</plurals>
<string name="screen_room_timeline_tombstoned_room_message">"Aquesta sala ha estat substituïda i ja no està activa"</string>
<string name="screen_room_timeline_upgraded_room_action">"Mostra els missatges antics"</string>
<string name="screen_room_timeline_upgraded_room_message">"Aquesta sala és la continuació d\'una altra"</string>
<plurals name="screen_room_typing_many_members">
<item quantity="one">"%1$s, %2$s i %3$d més"</item>
<item quantity="other">"%1$s, %2$s i %3$d més"</item>
</plurals>
<plurals name="screen_room_typing_notification">
<item quantity="one">"%1$s està escrivint"</item>
<item quantity="other">"%1$s estan escrivint"</item>
</plurals>
<string name="screen_room_typing_two_members">"%1$s i %2$s"</string>
</resources>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crypto_event_authenticity_mismatched_sender">"Odesílatel události se neshoduje s vlastníkem zařízení, které ji odeslalo."</string>
<string name="crypto_event_authenticity_not_guaranteed">"Autenticitu této zašifrované zprávy nelze na tomto zařízení zaručit."</string>
<string name="crypto_event_authenticity_not_guaranteed">"Pravost této šifrované zprávy nelze na tomto zařízení zaručit."</string>
<string name="crypto_event_authenticity_previously_verified">"Zašifrováno dříve ověřeným uživatelem."</string>
<string name="crypto_event_authenticity_sent_in_clear">"Není zašifrováno."</string>
<string name="crypto_event_authenticity_unknown_device">"Šifrováno neznámým nebo smazaným zařízením."</string>
@@ -35,7 +35,7 @@
<string name="screen_room_attachment_source_camera_video">"Optag video"</string>
<string name="screen_room_attachment_source_files">"Vedhæftning"</string>
<string name="screen_room_attachment_source_gallery">"Foto- og videobibliotek"</string>
<string name="screen_room_attachment_source_location">"Del lokation"</string>
<string name="screen_room_attachment_source_location">"Del placering"</string>
<string name="screen_room_attachment_source_poll">"Afstemning"</string>
<string name="screen_room_attachment_text_formatting">"Tekstformatering"</string>
<string name="screen_room_encrypted_history_banner">"Beskedhistorikken er i øjeblikket ikke tilgængelig."</string>
@@ -16,6 +16,18 @@
<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>
<item quantity="other">"%1$d kraadi"</item>
</plurals>
<string name="screen_image_edition_title">"Muuda fotot"</string>
<string name="screen_media_upload_preview_caption_warning">"Selgitused ja alapealkirjad ei pruugi olla nähtavad vanemate rakenduste kasutajatele."</string>
<string name="screen_media_upload_preview_change_video_quality_prompt">"Klõpsa üleslaaditava video kvaliteedi muutmiseks"</string>
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Faili üleslaadimine ei õnnestunud."</string>
@@ -26,6 +38,7 @@
<string name="screen_media_upload_preview_item_count">"Objekt %1$d/%2$d"</string>
<string name="screen_media_upload_preview_optimize_image_quality_title">"Optimeeri pildikvaliteeti"</string>
<string name="screen_media_upload_preview_processing">"Töötlen…"</string>
<string name="screen_media_upload_preview_title">"Lisa meediumi"</string>
<string name="screen_report_content_block_user">"Blokeeri kasutaja"</string>
<string name="screen_report_content_block_user_hint">"Vali see eelistus, kui sa soovid peita selle kasutaja kõik senised ja tulevased sõnumid"</string>
<string name="screen_report_content_explanation">"Teade selle sõnumi kohta edastatakse sinu koduserveri haldajale. Haldajal ei ole võimalik lugeda krüptitud sõnumite sisu."</string>
@@ -35,7 +48,7 @@
<string name="screen_room_attachment_source_camera_video">"Salvesta video"</string>
<string name="screen_room_attachment_source_files">"Manus"</string>
<string name="screen_room_attachment_source_gallery">"Fotode ja videote galerii"</string>
<string name="screen_room_attachment_source_location">"Asukoht"</string>
<string name="screen_room_attachment_source_location">"Jaga asukohta"</string>
<string name="screen_room_attachment_source_poll">"Küsitlus"</string>
<string name="screen_room_attachment_text_formatting">"Tekstivorming"</string>
<string name="screen_room_encrypted_history_banner">"Sõnumite ajalugu pole hetkel saadaval"</string>
@@ -27,7 +27,7 @@
<string name="screen_room_attachment_source_camera_video">"ضبط ویدیو"</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_location">"هم‌رسانی مکان"</string>
<string name="screen_room_attachment_source_poll">"نظرسنجی"</string>
<string name="screen_room_attachment_text_formatting">"قالب‌بندی متن"</string>
<string name="screen_room_encrypted_history_banner">"تاریخچه پیام درحال حاضر دردسترس نیست."</string>
@@ -56,5 +56,13 @@
<string name="screen_room_timeline_tombstoned_room_message">"این اتاق جایگزین شده و دیگر فعّال نیست"</string>
<string name="screen_room_timeline_upgraded_room_action">"دیدن پیام‌های قدیمی"</string>
<string name="screen_room_timeline_upgraded_room_message">"این اتاق ادامهٔ اتاقی دیگر است"</string>
<plurals name="screen_room_typing_many_members">
<item quantity="one">"%1$s، %2$s و %3$d سایر"</item>
<item quantity="other">"%1$s، %2$s و %3$d موارد دیگر"</item>
</plurals>
<plurals name="screen_room_typing_notification">
<item quantity="one">"%1$s در حال تایپ است"</item>
<item quantity="other">"%1$s در حال تایپ هستند"</item>
</plurals>
<string name="screen_room_typing_two_members">"%1$s و %2$s"</string>
</resources>
@@ -16,6 +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>
@@ -26,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">"Voyages &amp; lieux"</string>
<string name="emoji_picker_category_recent">"Emojis récents"</string>
<string name="emoji_picker_category_symbols">"Symboles"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally">"Retourner l’image horizontalement"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Retournée horizontalement"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Originale"</string>
<string name="screen_image_edition_a11y_flip_image_vertically">"Retourner l’image verticalement"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_flipped">"Retournée verticalement"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_original">"Originale"</string>
<string name="screen_image_edition_a11y_rotate_to_the_left">"Pivoter l’image vers la gauche"</string>
<plurals name="screen_image_edition_a11y_rotation_state">
<item quantity="one">"%1$d degré"</item>
<item quantity="other">"%1$d degrés"</item>
</plurals>
<string name="screen_image_edition_title">"Modifier la photo"</string>
<string name="screen_media_upload_preview_caption_warning">"Les légendes peuvent ne pas être visibles pour les utilisateurs d’anciennes applications."</string>
<string name="screen_media_upload_preview_change_video_quality_prompt">"Cliquez pour modifier la qualité d’envoi de la vidéo"</string>
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Le fichier n’a pas pu être envoyé."</string>
@@ -26,6 +38,7 @@
<string name="screen_media_upload_preview_item_count">"Élément %1$d sur %2$d"</string>
<string name="screen_media_upload_preview_optimize_image_quality_title">"Optimiser la qualité de l’image"</string>
<string name="screen_media_upload_preview_processing">"Traitement en cours…"</string>
<string name="screen_media_upload_preview_title">"Ajouter un média"</string>
<string name="screen_report_content_block_user">"Bloquer l’utilisateur"</string>
<string name="screen_report_content_block_user_hint">"Cochez si vous souhaitez masquer tous les messages actuels et futurs de cet utilisateur."</string>
<string name="screen_report_content_explanation">"Ce message sera signalé à l’administrateur de votre serveur d’accueil. Il ne pourra lire aucun message chiffré."</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>
@@ -34,7 +34,7 @@
<string name="screen_room_attachment_source_camera_video">"Rekam video"</string>
<string name="screen_room_attachment_source_files">"Lampiran"</string>
<string name="screen_room_attachment_source_gallery">"Pustaka Foto &amp; Video"</string>
<string name="screen_room_attachment_source_location">"Lokasi"</string>
<string name="screen_room_attachment_source_location">"Membagi Lokasi"</string>
<string name="screen_room_attachment_source_poll">"Jajak pendapat"</string>
<string name="screen_room_attachment_text_formatting">"Pemformatan Teks"</string>
<string name="screen_room_encrypted_history_banner">"Riwayat pesan saat ini tidak tersedia di ruangan ini"</string>
@@ -16,6 +16,11 @@
<string name="emoji_picker_category_places">"旅・場所"</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">"個数 %1$d / %2$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>
@@ -33,8 +39,8 @@
<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_gallery">"アルバムの写真・動画"</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>
<string name="screen_room_attachment_text_formatting">"書式設定"</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,19 @@
<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>
<item quantity="few">"%1$d stopnie"</item>
<item quantity="many">"%1$d stopni"</item>
</plurals>
<string name="screen_image_edition_title">"Edytuj zdjęcie"</string>
<string name="screen_media_upload_preview_caption_warning">"Opis może być niedostępny dla osób korzystających ze starszej wersji aplikacji."</string>
<string name="screen_media_upload_preview_change_video_quality_prompt">"Dotknij, aby zmienić jakość przesyłania wideo."</string>
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Nie udało się przesłać pliku."</string>
@@ -26,16 +39,17 @@
<string name="screen_media_upload_preview_item_count">"Pozycja %1$d z %2$d"</string>
<string name="screen_media_upload_preview_optimize_image_quality_title">"Zoptymalizuj jakość obrazu"</string>
<string name="screen_media_upload_preview_processing">"Przetwarzanie…"</string>
<string name="screen_media_upload_preview_title">"Dodaj media"</string>
<string name="screen_report_content_block_user">"Zablokuj użytkownika"</string>
<string name="screen_report_content_block_user_hint">"Sprawdź, czy chcesz ukryć wszystkie bieżące i przyszłe wiadomości od tego użytkownika."</string>
<string name="screen_report_content_explanation">"Ta wiadomość zostanie zgłoszona do administratora Twojego serwera domowego. Nie będzie mógł on przeczytać żadnych zaszyfrowanych wiadomości."</string>
<string name="screen_report_content_hint">"Powód zgłoszenia treści"</string>
<string name="screen_room_attachment_source_camera">"Kamera"</string>
<string name="screen_room_attachment_source_camera_photo">"Zrób zdjęcie"</string>
<string name="screen_room_attachment_source_camera_video">"Nagraj film"</string>
<string name="screen_room_attachment_source_camera_video">"Nagraj wideo"</string>
<string name="screen_room_attachment_source_files">"Załącznik"</string>
<string name="screen_room_attachment_source_gallery">"Zdjęcia i filmy"</string>
<string name="screen_room_attachment_source_location">"Lokalizacja"</string>
<string name="screen_room_attachment_source_location">"Udostępnij lokalizację"</string>
<string name="screen_room_attachment_source_poll">"Ankieta"</string>
<string name="screen_room_attachment_text_formatting">"Formatowanie tekstu"</string>
<string name="screen_room_encrypted_history_banner">"Historia wiadomości jest obecnie niedostępna."</string>
@@ -35,7 +35,7 @@
<string name="screen_room_attachment_source_camera_video">"Gravar vídeo"</string>
<string name="screen_room_attachment_source_files">"Anexo"</string>
<string name="screen_room_attachment_source_gallery">"Biblioteca de fotos e vídeos"</string>
<string name="screen_room_attachment_source_location">"Localização"</string>
<string name="screen_room_attachment_source_location">"Partilhar localização"</string>
<string name="screen_room_attachment_source_poll">"Sondagem"</string>
<string name="screen_room_attachment_text_formatting">"Formatação de texto"</string>
<string name="screen_room_encrypted_history_banner">"De momento, o histórico de mensagens está indisponível."</string>
@@ -16,16 +16,30 @@
<string name="emoji_picker_category_places">"Путешествия и места"</string>
<string name="emoji_picker_category_recent">"Недавние эмодзи"</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="one">"%1$d градус"</item>
<item quantity="few">"%1$d градуса"</item>
<item quantity="many">"%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>
<string name="screen_media_upload_preview_error_failed_processing">"Не удалось обработать медиафайл для загрузки, попробуйте еще раз."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Не удалось загрузить медиафайлы, попробуйте еще раз."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Не удалось обработать медиафайл для загрузки, попробуйте ещё раз."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Не удалось загрузить медиафайлы, попробуйте ещё раз."</string>
<string name="screen_media_upload_preview_error_too_large_message">"Максимальный размер файла: %1$s."</string>
<string name="screen_media_upload_preview_error_too_large_title">"Файл слишком большой для загрузки."</string>
<string name="screen_media_upload_preview_item_count">"%1$d из %2$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>
@@ -35,7 +35,7 @@
<string name="screen_room_attachment_source_camera_video">"Nahrať video"</string>
<string name="screen_room_attachment_source_files">"Príloha"</string>
<string name="screen_room_attachment_source_gallery">"Knižnica fotografií a videí"</string>
<string name="screen_room_attachment_source_location">"Poloha"</string>
<string name="screen_room_attachment_source_location">"Zdieľať polohu"</string>
<string name="screen_room_attachment_source_poll">"Anketa"</string>
<string name="screen_room_attachment_text_formatting">"Formátovanie textu"</string>
<string name="screen_room_encrypted_history_banner">"História správ v tejto miestnosti nie je momentálne k dispozícii"</string>
@@ -16,6 +16,13 @@
<string name="emoji_picker_category_places">"Подорожі та місця"</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="one">"%1$d градус"</item>
<item quantity="few">"%1$d градуси"</item>
<item quantity="many">"%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 +33,7 @@
<string name="screen_media_upload_preview_item_count">"Елемент %1$d з %2$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>
@@ -35,7 +43,7 @@
<string name="screen_room_attachment_source_camera_video">"Записати відео"</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_location">"Поділитися місцеперебуванням"</string>
<string name="screen_room_attachment_source_poll">"Опитування"</string>
<string name="screen_room_attachment_text_formatting">"Форматування тексту"</string>
<string name="screen_room_encrypted_history_banner">"Історія повідомлень наразі недоступна."</string>
@@ -16,18 +16,30 @@
<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>
</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>
<string name="screen_media_upload_preview_error_failed_processing">"处理要上传的媒体失败,请重试。"</string>
<string name="screen_media_upload_preview_error_failed_sending">"上传媒体失败,请重试。"</string>
<string name="screen_media_upload_preview_error_too_large_message">"允许的最大文件大小为%1$s 。"</string>
<string name="screen_media_upload_preview_error_too_large_message">"允许的最大文件大小为 %1$s。"</string>
<string name="screen_media_upload_preview_error_too_large_title">"文件太大,无法上传"</string>
<string name="screen_media_upload_preview_item_count">"第 %1$d 个项目,共 %2$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_block_user_hint">"请确认是否要隐藏该用户当前和未来的所有消息"</string>
<string name="screen_report_content_explanation">"此消息将举报给服务器管理员。他们无法读取任何加密消息。"</string>
<string name="screen_report_content_hint">"举报此内容的理由"</string>
<string name="screen_room_attachment_source_camera">"相机"</string>
@@ -16,6 +16,18 @@
<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>
<item quantity="other">"%1$d degrees"</item>
</plurals>
<string name="screen_image_edition_title">"Edit photo"</string>
<string name="screen_media_upload_preview_caption_warning">"Captions might not be visible to people using older apps."</string>
<string name="screen_media_upload_preview_change_video_quality_prompt">"Tap to change the video upload quality"</string>
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"The file could not be uploaded."</string>
@@ -26,6 +38,7 @@
<string name="screen_media_upload_preview_item_count">"Item %1$d of %2$d"</string>
<string name="screen_media_upload_preview_optimize_image_quality_title">"Optimise image quality"</string>
<string name="screen_media_upload_preview_processing">"Processing…"</string>
<string name="screen_media_upload_preview_title">"Add media"</string>
<string name="screen_report_content_block_user">"Block user"</string>
<string name="screen_report_content_block_user_hint">"Check if you want to hide all current and future messages from this user"</string>
<string name="screen_report_content_explanation">"This message will be reported to your homeserver’s administrator. They will not be able to read any encrypted messages."</string>
@@ -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 {
@@ -28,6 +28,7 @@ class FakeMessagesNavigator(
private val navigateToDeveloperSettingsLambda: () -> Unit = { lambdaError() },
private val onOpenThreadLambda: (threadRootId: ThreadId, focusedEventId: EventId?) -> Unit = { _, _ -> lambdaError() },
private val closeLambda: () -> Unit = { lambdaError() },
private val navigateToCurrentLiveLocationLambda: () -> Unit = { lambdaError() },
) : MessagesNavigator {
override fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
onShowEventDebugInfoClickLambda(eventId, debugInfo)
@@ -65,6 +66,10 @@ class FakeMessagesNavigator(
navigateToDeveloperSettingsLambda()
}
override fun navigateToCurrentLiveLocation() {
navigateToCurrentLiveLocationLambda()
}
override fun close() {
closeLambda()
}
@@ -13,6 +13,7 @@ package io.element.android.features.messages.impl
import androidx.lifecycle.Lifecycle
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.PinUnpinAction
import io.element.android.features.location.test.FakeActiveLiveLocationShareManager
import io.element.android.features.messages.impl.actionlist.ActionListEvent
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.anActionListState
@@ -120,6 +121,7 @@ import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@Suppress("LargeClass")
class MessagesPresenterTest {
@@ -140,6 +142,39 @@ class MessagesPresenterTest {
assertThat(initialState.snackbarMessage).isNull()
assertThat(initialState.inviteProgress).isEqualTo(AsyncData.Uninitialized)
assertThat(initialState.showReinvitePrompt).isFalse()
assertThat(initialState.showLiveLocationShareBanner).isFalse()
}
}
@Test
fun `present - exposes live location sharing banner visibility for current room`() = runTest {
val liveLocationShareManager = FakeActiveLiveLocationShareManager(
startShareLambda = { _, _ -> Result.success(Unit) },
)
liveLocationShareManager.startShare(A_ROOM_ID, 60.seconds)
val presenter = createMessagesPresenter(liveLocationShareManager = liveLocationShareManager)
presenter.testWithLifecycleOwner {
val state = consumeItemsUntilTimeout().last()
assertThat(state.showLiveLocationShareBanner).isTrue()
}
}
@Test
fun `present - stop live location share delegates to manager for current room`() = runTest {
val stopShareLambda = lambdaRecorder<RoomId, Result<Unit>> { Result.success(Unit) }
val liveLocationShareManager = FakeActiveLiveLocationShareManager(
stopShareLambda = stopShareLambda
)
val presenter = createMessagesPresenter(liveLocationShareManager = liveLocationShareManager)
presenter.testWithLifecycleOwner {
val state = consumeItemsUntilTimeout().last()
state.eventSink(MessagesEvent.StopLiveLocationShare)
advanceUntilIdle()
assert(stopShareLambda)
.isCalledOnce()
.with(value(A_ROOM_ID))
}
}
@@ -563,7 +598,7 @@ class MessagesPresenterTest {
baseRoom = FakeBaseRoom(
roomPermissions = roomPermissions(),
).apply {
givenRoomInfo(aRoomInfo(isDirect = true, joinedMembersCount = 1, activeMembersCount = 1))
givenRoomInfo(aRoomInfo(isDm = true, joinedMembersCount = 1, activeMembersCount = 1))
},
typingNoticeResult = { Result.success(Unit) },
)
@@ -1077,7 +1112,7 @@ class MessagesPresenterTest {
canRedactOwn = true,
canPinUnpin = true,
),
initialRoomInfo = aRoomInfo(isDirect = true, isEncrypted = true)
initialRoomInfo = aRoomInfo(isDm = true, isEncrypted = true)
).apply {
givenRoomMembersState(RoomMembersState.Ready(persistentListOf(aRoomMember(userId = A_SESSION_ID), aRoomMember(userId = A_USER_ID_2))))
},
@@ -1347,6 +1382,7 @@ class MessagesPresenterTest {
actionListEventSink: (ActionListEvent) -> Unit = {},
addRecentEmoji: AddRecentEmoji = AddRecentEmoji { _ -> lambdaError() },
markAsFullyRead: MarkAsFullyRead = FakeMarkAsFullyRead(),
liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(),
): MessagesPresenter {
return MessagesPresenter(
navigator = navigator,
@@ -1376,6 +1412,7 @@ class MessagesPresenterTest {
featureFlagService = featureFlagService,
addRecentEmoji = addRecentEmoji,
markAsFullyRead = markAsFullyRead,
liveLocationShareManager = liveLocationShareManager,
sessionCoroutineScope = backgroundScope,
)
}
@@ -6,13 +6,15 @@
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.messages.impl
import androidx.activity.ComponentActivity
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.longClick
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onAllNodesWithTag
@@ -25,8 +27,8 @@ import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeRight
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.compose.ui.text.AnnotatedString
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.emojibasebindings.Emoji
import io.element.android.emojibasebindings.EmojibaseCategory
import io.element.android.emojibasebindings.EmojibaseStore
@@ -75,85 +77,80 @@ import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.pressBack
import io.element.android.tests.testutils.robolectric.RobolectricTest
import io.element.android.tests.testutils.setSafeContent
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import kotlin.time.Duration.Companion.milliseconds
@RunWith(AndroidJUnit4::class)
class MessagesViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
class MessagesViewTest : RobolectricTest() {
@Test
fun `clicking on back invoke expected callback`() {
fun `clicking on back invoke expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<MessagesEvent>(expectEvents = false)
val state = aMessagesState(
eventSink = eventsRecorder
)
ensureCalledOnce { callback ->
rule.setMessagesView(
setMessagesView(
state = state,
onBackClick = callback,
)
rule.pressBack()
pressBack()
}
}
@Test
fun `clicking on room name invoke expected callback`() {
fun `clicking on room name invoke expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<MessagesEvent>(expectEvents = false)
val state = aMessagesState(
eventSink = eventsRecorder
)
ensureCalledOnce { callback ->
rule.setMessagesView(
setMessagesView(
state = state,
onRoomDetailsClick = callback,
)
rule.onNodeWithText(state.roomName.orEmpty(), useUnmergedTree = true).performClick()
onNodeWithText(state.roomName.orEmpty(), useUnmergedTree = true).performClick()
}
}
@Test
fun `clicking on join call invoke expected callback`() {
fun `clicking on join call invoke expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<MessagesEvent>(expectEvents = false)
val state = aMessagesState(
eventSink = eventsRecorder
)
ensureCalledOnceWithParam(false) { callback ->
rule.setMessagesView(
setMessagesView(
state = state,
onJoinCallClick = callback,
)
val joinCallContentDescription = rule.activity.getString(CommonStrings.a11y_start_call)
rule.onNodeWithContentDescription(joinCallContentDescription).performClick()
val joinCallContentDescription = activity!!.getString(CommonStrings.a11y_start_call)
onNodeWithContentDescription(joinCallContentDescription).performClick()
}
}
@Test
fun `clicking on join voice call invoke expected callback`() {
fun `clicking on join voice call invoke expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<MessagesEvent>(expectEvents = false)
val state = aMessagesState(
eventSink = eventsRecorder,
roomCallState = aStandByCallState(isDM = true)
)
ensureCalledOnceWithParam(true) { callback ->
rule.setMessagesView(
setMessagesView(
state = state,
onJoinCallClick = callback,
)
val joinVoiceCallContentDescription = rule.activity.getString(CommonStrings.a11y_start_voice_call)
rule.onNodeWithContentDescription(joinVoiceCallContentDescription).performClick()
val joinVoiceCallContentDescription = activity!!.getString(CommonStrings.a11y_start_voice_call)
onNodeWithContentDescription(joinVoiceCallContentDescription).performClick()
}
}
@Test
fun `clicking on an Event invoke expected callback`() {
fun `clicking on an Event invoke expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<MessagesEvent>(expectEvents = false)
val state = aMessagesState(
timelineState = aTimelineState(
@@ -167,12 +164,12 @@ class MessagesViewTest {
expectedParam2 = timelineItem,
result = true,
)
rule.setMessagesView(
setMessagesView(
state = state,
onEventClick = callback,
)
// Cannot perform click on "Text", it's not detected. Use tag instead
rule.onAllNodesWithTag(TestTags.messageBubble.value).onFirst().performClick()
onAllNodesWithTag(TestTags.messageBubble.value).onFirst().performClick()
callback.assertSuccess()
}
@@ -202,7 +199,7 @@ class MessagesViewTest {
userHasPermissionToRedactOther: Boolean = false,
userHasPermissionToSendReaction: Boolean = false,
userCanPinEvent: Boolean = false,
) {
) = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<ActionListEvent>()
val state = aMessagesState(
actionListState = anActionListState(
@@ -220,11 +217,11 @@ class MessagesViewTest {
),
)
val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event
rule.setMessagesView(
setMessagesView(
state = state,
)
// Cannot perform click on "Text", it's not detected. Use tag instead
rule.onAllNodesWithTag(TestTags.messageBubble.value).onFirst().performTouchInput { longClick() }
onAllNodesWithTag(TestTags.messageBubble.value).onFirst().performTouchInput { longClick() }
eventsRecorder.assertSingle(
ActionListEvent.ComputeForMessage(
event = timelineItem,
@@ -235,11 +232,10 @@ class MessagesViewTest {
@Test
@Config(qualifiers = "h1024dp")
fun `clicking on a read receipt list emits the expected Event`() {
fun `clicking on a read receipt list emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<ReadReceiptBottomSheetEvent>()
val state = aMessagesState(
timelineState = aTimelineState(
renderReadReceipts = true,
timelineItems = persistentListOf(
aTimelineItemEvent(
readReceiptState = aTimelineItemReadReceipts(
@@ -255,10 +251,10 @@ class MessagesViewTest {
),
)
val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event
rule.setMessagesView(
setMessagesView(
state = state,
)
rule.onNodeWithTag(TestTags.messageReadReceipts.value, useUnmergedTree = true).performClick()
onNodeWithTag(TestTags.messageReadReceipts.value, useUnmergedTree = true).performClick()
eventsRecorder.assertSingle(ReadReceiptBottomSheetEvent.EventSelected(timelineItem))
}
@@ -272,7 +268,7 @@ class MessagesViewTest {
swipeTest(userHasPermissionToSendMessage = false)
}
private fun swipeTest(userHasPermissionToSendMessage: Boolean) {
private fun swipeTest(userHasPermissionToSendMessage: Boolean) = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<MessagesEvent>()
val canBeRepliedEvent = aTimelineItemEvent(canBeRepliedTo = true)
val cannotBeRepliedEvent = aTimelineItemEvent(canBeRepliedTo = false)
@@ -285,10 +281,10 @@ class MessagesViewTest {
),
eventSink = eventsRecorder,
)
rule.setMessagesView(
setMessagesView(
state = state,
)
rule.onAllNodesWithTag(TestTags.messageBubble.value).apply {
onAllNodesWithTag(TestTags.messageBubble.value).apply {
onFirst().performTouchInput { swipeRight(endX = 200f) }
onLast().performTouchInput { swipeRight(endX = 200f) }
}
@@ -300,7 +296,7 @@ class MessagesViewTest {
}
@Test
fun `clicking on send location invoke expected callback`() {
fun `clicking on send location invoke expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<MessagesEvent>(expectEvents = false)
val state = aMessagesState(
composerState = aMessageComposerState(
@@ -309,16 +305,16 @@ class MessagesViewTest {
eventSink = eventsRecorder
)
ensureCalledOnce { callback ->
rule.setMessagesView(
setMessagesView(
state = state,
onSendLocationClick = callback,
)
rule.clickOn(R.string.screen_room_attachment_source_location)
clickOn(R.string.screen_room_attachment_source_location)
}
}
@Test
fun `clicking on create poll invoke expected callback`() {
fun `clicking on create poll invoke expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<MessagesEvent>(expectEvents = false)
val state = aMessagesState(
composerState = aMessageComposerState(
@@ -327,25 +323,25 @@ class MessagesViewTest {
eventSink = eventsRecorder
)
ensureCalledOnce { callback ->
rule.setMessagesView(
setMessagesView(
state = state,
onCreatePollClick = callback,
)
// Then click on the poll action
rule.clickOn(R.string.screen_room_attachment_source_poll)
clickOn(R.string.screen_room_attachment_source_poll)
}
}
@Test
@Config(qualifiers = "h1024dp")
fun `clicking on the avatar of the sender of an Event emits the expected event`() {
fun `clicking on the avatar of the sender of an Event emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<MessagesEvent>()
val state = aMessagesState(
eventSink = eventsRecorder
)
val timelineEvent = state.timelineState.timelineItems.filterIsInstance<TimelineItem.Event>().first()
rule.setMessagesView(state = state)
rule.onNodeWithTag(TestTags.timelineItemSenderAvatar.value, useUnmergedTree = true).performClick()
setMessagesView(state = state)
onNodeWithTag(TestTags.timelineItemSenderAvatar.value, useUnmergedTree = true).performClick()
eventsRecorder.assertSingle(
MessagesEvent.OnUserClicked(
MatrixUser(
@@ -359,12 +355,12 @@ class MessagesViewTest {
@Test
@Config(qualifiers = "h1024dp")
fun `clicking on the display name of the sender of an Event emits expected event`() {
fun `clicking on the display name of the sender of an Event emits expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<MessagesEvent>()
val state = aMessagesState(eventSink = eventsRecorder)
val timelineEvent = state.timelineState.timelineItems.filterIsInstance<TimelineItem.Event>().first()
rule.setMessagesView(state = state)
rule.onNodeWithTag(TestTags.timelineItemSenderAvatar.value, useUnmergedTree = true).performClick()
setMessagesView(state = state)
onNodeWithTag(TestTags.timelineItemSenderAvatar.value, useUnmergedTree = true).performClick()
eventsRecorder.assertSingle(
MessagesEvent.OnUserClicked(
MatrixUser(
@@ -377,7 +373,7 @@ class MessagesViewTest {
}
@Test
fun `selecting a action on a message emits the expected Event`() {
fun `selecting a action on a message emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<MessagesEvent>()
val state = aMessagesState(
eventSink = eventsRecorder
@@ -395,17 +391,17 @@ class MessagesViewTest {
)
),
)
rule.setMessagesView(
setMessagesView(
state = stateWithMessageAction,
)
rule.clickOn(CommonStrings.action_edit)
clickOn(CommonStrings.action_edit)
// Give time for the close animation to complete
rule.mainClock.advanceTimeBy(milliseconds = 1_000)
mainClock.advanceTimeBy(milliseconds = 1_000)
eventsRecorder.assertSingle(MessagesEvent.HandleAction(TimelineItemAction.Edit, timelineItem))
}
@Test
fun `clicking on a reaction emits the expected Event`() {
fun `clicking on a reaction emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<MessagesEvent>()
val state = aMessagesState(
timelineState = aTimelineState(
@@ -414,10 +410,10 @@ class MessagesViewTest {
eventSink = eventsRecorder,
)
val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event
rule.setMessagesView(
setMessagesView(
state = state,
)
rule.onAllNodesWithText(
onAllNodesWithText(
text = "👍️",
useUnmergedTree = true,
).onFirst().performClick()
@@ -425,7 +421,7 @@ class MessagesViewTest {
}
@Test
fun `long clicking on a reaction emits the expected Event`() {
fun `long clicking on a reaction emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<ReactionSummaryEvent>()
val state = aMessagesState(
timelineState = aTimelineState(
@@ -437,10 +433,10 @@ class MessagesViewTest {
),
)
val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event
rule.setMessagesView(
setMessagesView(
state = state,
)
rule.onAllNodesWithText(
onAllNodesWithText(
text = "👍️",
useUnmergedTree = true,
).onFirst().performTouchInput { longClick() }
@@ -448,7 +444,7 @@ class MessagesViewTest {
}
@Test
fun `clicking on more reaction emits the expected Event`() {
fun `clicking on more reaction emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<CustomReactionEvent>()
val state = aMessagesState(
timelineState = aTimelineState(
@@ -459,16 +455,16 @@ class MessagesViewTest {
),
)
val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event
rule.setMessagesView(
setMessagesView(
state = state,
)
val moreReactionContentDescription = rule.activity.getString(R.string.screen_room_timeline_add_reaction)
rule.onAllNodesWithContentDescription(moreReactionContentDescription).onFirst().performClick()
val moreReactionContentDescription = activity!!.getString(R.string.screen_room_timeline_add_reaction)
onAllNodesWithContentDescription(moreReactionContentDescription).onFirst().performClick()
eventsRecorder.assertSingle(CustomReactionEvent.ShowCustomReactionSheet(timelineItem))
}
@Test
fun `clicking on more reaction from action list emits the expected Event`() {
fun `clicking on more reaction from action list emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<CustomReactionEvent>()
val state = aMessagesState(
timelineState = aTimelineState(
@@ -491,18 +487,18 @@ class MessagesViewTest {
eventSink = eventsRecorder
),
)
rule.setMessagesView(
setMessagesView(
state = stateWithActionListState,
)
val moreReactionContentDescription = rule.activity.getString(CommonStrings.a11y_react_with_other_emojis)
rule.onNodeWithContentDescription(moreReactionContentDescription).performClick()
val moreReactionContentDescription = activity!!.getString(CommonStrings.a11y_react_with_other_emojis)
onNodeWithContentDescription(moreReactionContentDescription).performClick()
// Give time for the close animation to complete
rule.mainClock.advanceTimeBy(milliseconds = 1_000)
mainClock.advanceTimeBy(milliseconds = 1_000)
eventsRecorder.assertSingle(CustomReactionEvent.ShowCustomReactionSheet(timelineItem))
}
@Test
fun `clicking on verified user send failure from action list emits the expected Event`() {
fun `clicking on verified user send failure from action list emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<TimelineEvent>()
val state = aMessagesState()
val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event
@@ -519,21 +515,21 @@ class MessagesViewTest {
),
timelineState = aTimelineState(eventSink = eventsRecorder)
)
rule.setMessagesView(
setMessagesView(
state = stateWithActionListState,
)
// Clear initial 'LoadMore' event emitted when setting the state
eventsRecorder.clear()
val verifiedUserSendFailure = rule.activity.getString(CommonStrings.screen_timeline_item_menu_send_failure_changed_identity, "Alice")
rule.onNodeWithText(verifiedUserSendFailure).performClick()
val verifiedUserSendFailure = activity!!.getString(CommonStrings.screen_timeline_item_menu_send_failure_changed_identity, "Alice")
onNodeWithText(verifiedUserSendFailure).performClick()
// Give time for the close animation to complete
rule.mainClock.advanceTimeBy(milliseconds = 1_000)
mainClock.advanceTimeBy(milliseconds = 1_000)
eventsRecorder.assertSingle(TimelineEvent.ComputeVerifiedUserSendFailure(timelineItem))
}
@Test
fun `clicking on a custom emoji emits the expected Events`() {
fun `clicking on a custom emoji emits the expected Events`() = runAndroidComposeUiTest {
val aUnicode = "🙈"
val customReactionStateEventsRecorder = EventsRecorder<CustomReactionEvent>()
val eventsRecorder = EventsRecorder<MessagesEvent>()
@@ -563,18 +559,18 @@ class MessagesViewTest {
eventSink = customReactionStateEventsRecorder
),
)
rule.setMessagesView(
setMessagesView(
state = stateWithCustomReactionState,
)
rule.onNodeWithText(aUnicode, useUnmergedTree = true).performClick()
onNodeWithText(aUnicode, useUnmergedTree = true).performClick()
// Give time for the close animation to complete
rule.mainClock.advanceTimeBy(milliseconds = 1_000)
mainClock.advanceTimeBy(milliseconds = 1_000)
customReactionStateEventsRecorder.assertSingle(CustomReactionEvent.DismissCustomReactionSheet)
eventsRecorder.assertSingle(MessagesEvent.ToggleReaction(aUnicode, timelineItem.eventOrTransactionId))
}
@Test
fun `clicking on pinned messages banner emits the expected Event`() {
fun `clicking on pinned messages banner emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<TimelineEvent>()
val state = aMessagesState(
timelineState = aTimelineState(eventSink = eventsRecorder),
@@ -587,16 +583,16 @@ class MessagesViewTest {
),
),
)
rule.setMessagesView(state = state)
setMessagesView(state = state)
// Clear initial 'LoadMore' event emitted when setting the state
eventsRecorder.clear()
rule.onNodeWithText("This is a pinned message").performClick()
onNodeWithText("This is a pinned message").performClick()
eventsRecorder.assertSingle(TimelineEvent.FocusOnEvent(AN_EVENT_ID, debounce = FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS.milliseconds))
}
@Test
fun `clicking on successor room button emits expected event`() {
fun `clicking on successor room button emits expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<TimelineEvent>()
val successorRoomId = RoomId("!successor:server.org")
val state = aMessagesState(
@@ -606,18 +602,18 @@ class MessagesViewTest {
),
timelineState = aTimelineState(eventSink = eventsRecorder)
)
rule.setMessagesView(state = state)
setMessagesView(state = state)
// Clear initial 'LoadMore' event emitted when setting the state
eventsRecorder.clear()
val text = rule.activity.getString(R.string.screen_room_timeline_tombstoned_room_action)
val text = activity!!.getString(R.string.screen_room_timeline_tombstoned_room_action)
// The bottomsheet subcompose seems to make the node to appear twice
rule.onAllNodesWithText(text).onFirst().performClick()
onAllNodesWithText(text).onFirst().performClick()
eventsRecorder.assertSingle(TimelineEvent.NavigateToPredecessorOrSuccessorRoom(successorRoomId))
}
@Test
fun `clicking on threads list button calls the expected function`() {
fun `clicking on threads list button calls the expected function`() = runAndroidComposeUiTest {
val state = aMessagesState(
threads = MessagesState.Threads(
hasThreads = true,
@@ -625,28 +621,66 @@ class MessagesViewTest {
)
)
val onThreadsListClicked = lambdaRecorder<Unit> {}
rule.setMessagesView(
setMessagesView(
state = state,
onThreadsListClicked = onThreadsListClicked,
)
rule.onNodeWithContentDescription("Threads").performClick()
onNodeWithContentDescription("Threads").performClick()
onThreadsListClicked.assertions().isCalledOnce()
}
@Test
fun `no banner shown when there is no successor room`() {
fun `no banner shown when there is no successor room`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<MessagesEvent>(expectEvents = false)
val state = aMessagesState(
successorRoom = null,
eventSink = eventsRecorder
)
rule.setMessagesView(state = state)
rule.assertNoNodeWithText(R.string.screen_room_timeline_tombstoned_room_message)
rule.assertNoNodeWithText(R.string.screen_room_timeline_tombstoned_room_action)
setMessagesView(state = state)
assertNoNodeWithText(R.string.screen_room_timeline_tombstoned_room_message)
assertNoNodeWithText(R.string.screen_room_timeline_tombstoned_room_action)
}
@Test
fun `live location banner is visible when current room is sharing`() = runAndroidComposeUiTest {
val state = aMessagesState(isCurrentlySharingLiveLocationInRoom = true)
setMessagesView(state = state)
onNodeWithText(activity!!.getString(CommonStrings.screen_room_live_location_banner)).assertExists()
}
@Test
fun `live location banner is hidden when current room is not sharing`() = runAndroidComposeUiTest {
val state = aMessagesState(isCurrentlySharingLiveLocationInRoom = false)
setMessagesView(state = state)
onNodeWithText(activity!!.getString(CommonStrings.screen_room_live_location_banner)).assertDoesNotExist()
}
@Test
fun `clicking stop on live location banner emits expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<MessagesEvent>()
val state = aMessagesState(
isCurrentlySharingLiveLocationInRoom = true,
eventSink = eventsRecorder,
)
setMessagesView(state = state)
clickOn(CommonStrings.action_stop)
eventsRecorder.assertSingle(MessagesEvent.StopLiveLocationShare)
}
@Test
fun `clicking live location banner emit expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<MessagesEvent>()
val state = aMessagesState(
isCurrentlySharingLiveLocationInRoom = true,
eventSink = eventsRecorder,
)
setMessagesView(state = state)
clickOn(CommonStrings.screen_room_live_location_banner)
eventsRecorder.assertSingle(MessagesEvent.ShowLiveLocationShare)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessagesView(
private fun AndroidComposeUiTest<ComponentActivity>.setMessagesView(
state: MessagesState,
onBackClick: () -> Unit = EnsureNeverCalled(),
onRoomDetailsClick: () -> Unit = EnsureNeverCalled(),
@@ -17,6 +17,7 @@ import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUser
import io.element.android.features.messages.impl.fixtures.aMessageEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
import io.element.android.features.messages.impl.timeline.model.event.RtcNotificationState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
@@ -1169,7 +1170,7 @@ class ActionListPresenterTest {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
content = TimelineItemRtcNotificationContent(callIntent = CallIntent.VIDEO),
content = TimelineItemRtcNotificationContent(callIntent = CallIntent.VIDEO, state = RtcNotificationState.Started),
)
initialState.eventSink.invoke(
ActionListEvent.ComputeForMessage(
@@ -11,12 +11,19 @@
package io.element.android.features.messages.impl.attachments
import android.net.Uri
import androidx.core.net.toUri
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewEvent
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewPresenter
import io.element.android.features.messages.impl.attachments.preview.OnDoneListener
import io.element.android.features.messages.impl.attachments.preview.SendActionState
import io.element.android.features.messages.impl.attachments.preview.imageeditor.AttachmentImageEditor
import io.element.android.features.messages.impl.attachments.preview.imageeditor.AttachmentImageEdits
import io.element.android.features.messages.impl.attachments.preview.imageeditor.EditedLocalMedia
import io.element.android.features.messages.impl.attachments.preview.imageeditor.NormalizedCropRect
import io.element.android.features.messages.impl.attachments.preview.imageeditor.assertIsSimilarTo
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState
import io.element.android.features.messages.impl.attachments.video.VideoCompressionPresetSelector
import io.element.android.features.messages.impl.attachments.video.VideoUploadEstimation
import io.element.android.features.messages.impl.fixtures.aMediaAttachment
import io.element.android.features.messages.test.attachments.video.FakeMediaOptimizationSelectorPresenterFactory
@@ -45,6 +52,7 @@ import io.element.android.libraries.mediaupload.test.FakeMediaOptimizationConfig
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo
import io.element.android.libraries.mediaviewer.api.anApkMediaInfo
import io.element.android.libraries.mediaviewer.api.anImageMediaInfo
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
@@ -56,6 +64,7 @@ import io.element.android.tests.testutils.lambda.any
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.robolectric.RobolectricTest
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import io.mockk.every
@@ -68,13 +77,11 @@ import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.io.File
import kotlin.io.path.createTempFile
@Suppress("LargeClass")
@RunWith(RobolectricTestRunner::class)
class AttachmentsPreviewPresenterTest {
class AttachmentsPreviewPresenterTest : RobolectricTest() {
@get:Rule
val warmUpRule = WarmUpRule()
@@ -548,10 +555,319 @@ class AttachmentsPreviewPresenterTest {
}
}
@Test
fun `present - applying image edits updates the attachment`() = runTest {
val editedUri = Uri.parse("file:///tmp/edited.jpeg")
val presenter = createAttachmentsPreviewPresenter(
displayMediaQualitySelectorViews = true,
attachmentImageEditor = FakeAttachmentImageEditor {
Result.success(
EditedLocalMedia(
localMedia = aLocalMedia(uri = editedUri),
file = File("/tmp/edited.jpeg"),
)
)
}
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(AttachmentsPreviewEvent.OpenImageEditor)
val editorState = awaitItem()
assertThat(editorState.imageEditorState).isNotNull()
editorState.eventSink(AttachmentsPreviewEvent.RotateImageToTheLeft)
val rotatedState = awaitItem()
assertThat(rotatedState.imageEditorState?.edits?.rotationQuarterTurns).isEqualTo(3)
rotatedState.eventSink(AttachmentsPreviewEvent.ApplyImageEdits)
assertThat(awaitItem().isApplyingImageEdits).isTrue()
val appliedState = awaitItem()
assertThat((appliedState.attachment as Attachment.Media).localMedia.uri).isEqualTo(editedUri)
assertThat(appliedState.imageEditorState).isNull()
assertThat(appliedState.isApplyingImageEdits).isFalse()
}
}
@Test
fun `present - reopening image editor keeps original media and previous edits`() = runTest {
val editedUri = Uri.parse("file:///tmp/edited.jpeg")
val originalLocalMedia = aLocalMedia(uri = mockMediaUrl)
val cropRect = NormalizedCropRect(
left = 0.2f,
top = 0.15f,
right = 0.85f,
bottom = 0.9f,
)
val presenter = createAttachmentsPreviewPresenter(
localMedia = originalLocalMedia,
displayMediaQualitySelectorViews = true,
attachmentImageEditor = FakeAttachmentImageEditor {
Result.success(
EditedLocalMedia(
localMedia = aLocalMedia(uri = editedUri),
file = File("/tmp/edited.jpeg"),
)
)
}
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(AttachmentsPreviewEvent.OpenImageEditor)
val editorState = consumeItemsUntilPredicate { it.imageEditorState != null }.last()
editorState.eventSink(AttachmentsPreviewEvent.UpdateImageCropRect(cropRect))
val croppedState = awaitItem()
croppedState.eventSink(AttachmentsPreviewEvent.RotateImageToTheLeft)
val rotatedState = awaitItem()
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)
appliedState.eventSink(AttachmentsPreviewEvent.OpenImageEditor)
val reopenedState = consumeItemsUntilPredicate { it.imageEditorState != null }.last()
assertThat(reopenedState.imageEditorState!!.localMedia.uri).isEqualTo(originalLocalMedia.uri)
val rotatedCropRect = NormalizedCropRect(
left = cropRect.top,
top = 1f - cropRect.right,
right = cropRect.bottom,
bottom = 1f - cropRect.left,
)
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()
}
}
fun `present - sendAsFile attachment is pre-processed without image compression`() = runTest {
// Even though the user has enabled "Optimize media quality" globally, picking the file
// through the Files picker (sendAsFile = true) must skip compression. Regression test
// for https://github.com/element-hq/element-x-android/issues/6365
val mediaPreProcessor = FakeMediaPreProcessor()
val presenter = createAttachmentsPreviewPresenter(
localMedia = aLocalMedia(mockMediaUrl, anImageMediaInfo()),
sendAsFile = true,
mediaPreProcessor = mediaPreProcessor,
// Selector views are hidden in the sendAsFile flow, which triggers the auto pre-process path.
displayMediaQualitySelectorViews = false,
mediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(
config = MediaOptimizationConfig(
compressImages = true,
videoCompressionPreset = VideoCompressionPreset.STANDARD,
)
),
)
presenter.test {
consumeItemsUntilPredicate { mediaPreProcessor.processCallCount > 0 }
assertThat(mediaPreProcessor.lastMediaOptimizationConfig).isEqualTo(
MediaOptimizationConfig(
compressImages = false,
videoCompressionPreset = VideoCompressionPreset.HIGH,
)
)
}
}
@Test
fun `present - sending edited media keeps the edited file available until upload starts`() = runTest {
val editedFile = createTempFile(suffix = ".jpeg").toFile().apply {
writeText("edited-media")
}
val sendFileResult =
lambdaRecorder<File, FileInfo, String?, String?, EventId?, Result<FakeMediaUploadHandler>> { file, _, _, _, _ ->
assertThat(file.exists()).isTrue()
Result.success(FakeMediaUploadHandler())
}
val room = FakeJoinedRoom(
liveTimeline = FakeTimeline().apply {
sendFileLambda = sendFileResult
},
)
val presenter = createAttachmentsPreviewPresenter(
room = room,
displayMediaQualitySelectorViews = true,
onDoneListener = OnDoneListener {},
mediaPreProcessor = FakeMediaPreProcessor().apply {
givenResult(
Result.success(
MediaUploadInfo.AnyFile(
file = editedFile,
fileInfo = FileInfo(
mimetype = MimeTypes.Jpeg,
size = editedFile.length(),
thumbnailInfo = null,
thumbnailSource = null,
)
)
)
)
},
attachmentImageEditor = FakeAttachmentImageEditor {
Result.success(
EditedLocalMedia(
localMedia = aLocalMedia(uri = editedFile.toUri()),
file = editedFile,
)
)
}
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(AttachmentsPreviewEvent.OpenImageEditor)
val editorState = consumeItemsUntilPredicate { it.imageEditorState != null }.last()
editorState.eventSink(AttachmentsPreviewEvent.ApplyImageEdits)
val appliedState = consumeItemsUntilPredicate { !it.isApplyingImageEdits && it.imageEditorState == null }.last()
appliedState.eventSink(AttachmentsPreviewEvent.SendAttachment)
consumeItemsUntilPredicate { it.sendActionState == SendActionState.Done }
sendFileResult.assertions().isCalledOnce()
}
}
@Test
fun `present - image with generic mime type and png extension is still editable`() = runTest {
val localMedia = aLocalMedia(
uri = mockMediaUrl,
mediaInfo = anImageMediaInfo().copy(
mimeType = MimeTypes.OctetStream,
filename = "Screenshot.png",
fileExtension = "png",
),
)
val presenter = createAttachmentsPreviewPresenter(localMedia = localMedia)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.canEditImage).isTrue()
initialState.eventSink(AttachmentsPreviewEvent.OpenImageEditor)
val editorState = consumeItemsUntilPredicate { it.imageEditorState != null }.last()
assertThat(editorState.imageEditorState).isNotNull()
}
}
@Test
fun `present - image can still be edited when editor can decode it despite generic media info`() = runTest {
val localMedia = aLocalMedia(
uri = mockMediaUrl,
mediaInfo = anImageMediaInfo().copy(
mimeType = MimeTypes.OctetStream,
filename = "",
fileExtension = "",
),
)
val presenter = createAttachmentsPreviewPresenter(
localMedia = localMedia,
attachmentImageEditor = FakeAttachmentImageEditor(
canEditResult = true,
) {
Result.success(
EditedLocalMedia(
localMedia = localMedia.copy(uri = Uri.parse("file:///tmp/decoded.jpeg")),
file = File("/tmp/decoded.jpeg"),
)
)
}
)
presenter.test {
val initialState = consumeItemsUntilPredicate { it.canEditImage }.last()
assertThat(initialState.canEditImage).isTrue()
initialState.eventSink(AttachmentsPreviewEvent.OpenImageEditor)
val editorState = consumeItemsUntilPredicate { it.imageEditorState != null }.last()
assertThat(editorState.imageEditorState).isNotNull()
}
}
@Test
fun `present - sendAsFile video is pre-processed with best fitting preset`() = runTest {
val mediaPreProcessor = FakeMediaPreProcessor()
val presenter = createAttachmentsPreviewPresenter(
localMedia = aLocalMedia(mockMediaUrl, aVideoMediaInfo()),
sendAsFile = true,
mediaPreProcessor = mediaPreProcessor,
// Selector views are hidden in the sendAsFile flow, which triggers the auto pre-process path.
displayMediaQualitySelectorViews = false,
mediaOptimizationSelectorPresenterFactory = FakeMediaOptimizationSelectorPresenterFactory {
MediaOptimizationSelectorState(
maxUploadSize = AsyncData.Success(250_000_000L),
videoSizeEstimations = AsyncData.Success(
persistentListOf(
VideoUploadEstimation(VideoCompressionPreset.HIGH, sizeInBytes = 513_216_000L, canUpload = false),
VideoUploadEstimation(VideoCompressionPreset.STANDARD, sizeInBytes = 228_096_000L, canUpload = true),
VideoUploadEstimation(VideoCompressionPreset.LOW, sizeInBytes = 57_024_000L, canUpload = true),
)
),
isImageOptimizationEnabled = false,
selectedVideoPreset = VideoCompressionPreset.STANDARD,
displayMediaSelectorViews = false,
displayVideoPresetSelectorDialog = false,
eventSink = {},
)
},
mediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(
config = MediaOptimizationConfig(
compressImages = true,
videoCompressionPreset = VideoCompressionPreset.LOW,
)
),
)
presenter.test {
consumeItemsUntilPredicate { mediaPreProcessor.processCallCount > 0 }
assertThat(mediaPreProcessor.lastMediaOptimizationConfig).isEqualTo(
MediaOptimizationConfig(
compressImages = false,
videoCompressionPreset = VideoCompressionPreset.STANDARD,
)
)
}
}
private fun TestScope.createAttachmentsPreviewPresenter(
localMedia: LocalMedia = aLocalMedia(
uri = mockMediaUrl,
),
sendAsFile: Boolean = false,
room: JoinedRoom = FakeJoinedRoom(),
timelineMode: Timeline.Mode = Timeline.Mode.Live,
permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(),
@@ -573,9 +889,18 @@ class AttachmentsPreviewPresenterTest {
}
),
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
attachmentImageEditor: AttachmentImageEditor = FakeAttachmentImageEditor {
Result.success(
EditedLocalMedia(
localMedia = localMedia.copy(uri = Uri.parse("file:///tmp/default-edited.jpeg")),
file = File("/tmp/default-edited.jpeg"),
)
)
},
videoCompressionPresetSelector: VideoCompressionPresetSelector = VideoCompressionPresetSelector(),
): AttachmentsPreviewPresenter {
return AttachmentsPreviewPresenter(
attachment = aMediaAttachment(localMedia),
attachment = aMediaAttachment(localMedia, sendAsFile = sendAsFile),
onDoneListener = onDoneListener,
mediaSenderFactory = MediaSenderFactory { timelineMode ->
DefaultMediaSender(
@@ -589,15 +914,33 @@ class AttachmentsPreviewPresenterTest {
},
permalinkBuilder = permalinkBuilder,
temporaryUriDeleter = temporaryUriDeleter,
attachmentImageEditor = attachmentImageEditor,
sessionCoroutineScope = this,
dispatchers = testCoroutineDispatchers(),
mediaOptimizationSelectorPresenterFactory = mediaOptimizationSelectorPresenterFactory,
videoCompressionPresetSelector = videoCompressionPresetSelector,
timelineMode = timelineMode,
inReplyToEventId = null,
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
)
}
private class FakeAttachmentImageEditor(
private val canEditResult: Boolean = true,
private val result: () -> Result<EditedLocalMedia>,
) : AttachmentImageEditor {
override suspend fun canEdit(localMedia: LocalMedia): Boolean {
return canEditResult
}
override suspend fun exportEdits(
localMedia: LocalMedia,
edits: AttachmentImageEdits,
): Result<EditedLocalMedia> {
return result()
}
}
private val mediaUploadInfo = MediaUploadInfo.AnyFile(
File("test"),
FileInfo(
@@ -0,0 +1,78 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.attachments.preview.imageeditor
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class AttachmentImageEditsTest {
@Test
fun `rotate normalizes after a full turn`() {
var edits = AttachmentImageEdits()
repeat(4) {
edits = edits.rotateAntiClockwise()
}
assertThat(edits.normalizedRotationQuarterTurns).isEqualTo(0)
assertThat(edits.rotationDegrees).isEqualTo(0)
assertThat(edits.hasChanges).isFalse()
}
@Test
fun `rotate updates rotation and crop`() {
val sut = AttachmentImageEdits(
cropRect = NormalizedCropRect(
left = 0.2f,
top = 0.3f,
right = 0.8f,
bottom = 0.9f,
),
rotationQuarterTurns = 0,
)
val result = sut.rotateAntiClockwise()
assertThat(result.normalizedRotationQuarterTurns).isEqualTo(3)
assertThat(result.rotationDegrees).isEqualTo(270)
assertThat(result.cropRect.left).isWithin(0.0001f).of(0.3f)
assertThat(result.cropRect.top).isWithin(0.0001f).of(0.2f)
assertThat(result.cropRect.right).isWithin(0.0001f).of(0.9f)
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()
}
}
@@ -0,0 +1,24 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.attachments.preview.imageeditor
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.core.mimetype.MimeTypes
import org.junit.Test
class DefaultAttachmentImageEditorTest {
@Test
fun `exported mime type preserves png`() {
assertThat(exportedMimeTypeFor(MimeTypes.Png)).isEqualTo(MimeTypes.Png)
}
@Test
fun `exported mime type normalizes non-png images to jpeg`() {
assertThat(exportedMimeTypeFor("image/heic")).isEqualTo(MimeTypes.Jpeg)
}
}

Some files were not shown because too many files have changed in this diff Show More