mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-07 01:03:44 +00:00
Merge branch 'develop' into misc/reactions-pinned-messages
This commit is contained in:
@@ -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)
|
||||
|
||||
+2
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+122
-24
@@ -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
|
||||
) {
|
||||
|
||||
+1
@@ -26,5 +26,6 @@ interface MessagesNavigator {
|
||||
fun navigateToMember(userId: UserId)
|
||||
fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?)
|
||||
fun navigateToDeveloperSettings()
|
||||
fun navigateToCurrentLiveLocation()
|
||||
fun close()
|
||||
}
|
||||
|
||||
+20
-4
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
+21
-2
@@ -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)
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+6
-2
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
+48
-37
@@ -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
|
||||
) {
|
||||
|
||||
+3
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
+8
-1
@@ -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
|
||||
}
|
||||
|
||||
+44
@@ -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
|
||||
}
|
||||
}
|
||||
+11
@@ -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
|
||||
}
|
||||
|
||||
+191
-26
@@ -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?,
|
||||
|
||||
+5
@@ -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,
|
||||
|
||||
+10
@@ -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,
|
||||
|
||||
+142
-47
@@ -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) }
|
||||
) {
|
||||
|
||||
+202
@@ -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
|
||||
}
|
||||
+197
@@ -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
|
||||
}
|
||||
}
|
||||
+102
@@ -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,
|
||||
)
|
||||
+691
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
+19
-19
@@ -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")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -15,6 +15,7 @@ fun interface MediaOptimizationSelectorPresenter : Presenter<MediaOptimizationSe
|
||||
interface Factory {
|
||||
fun create(
|
||||
localMedia: LocalMedia,
|
||||
sendAsFile: Boolean,
|
||||
): MediaOptimizationSelectorPresenter
|
||||
}
|
||||
}
|
||||
|
||||
+31
@@ -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")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
-1
@@ -30,7 +30,6 @@ interface VideoMetadataExtractor : AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
@AssistedInject
|
||||
class DefaultVideoMetadataExtractor(
|
||||
@ApplicationContext private val context: Context,
|
||||
|
||||
+2
-1
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
+3
-2
@@ -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,
|
||||
)
|
||||
|
||||
-8
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+11
-12
@@ -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) {
|
||||
|
||||
+3
-1
@@ -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"),
|
||||
|
||||
+7
-2
@@ -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 = {
|
||||
|
||||
+2
-2
@@ -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)
|
||||
|
||||
|
||||
+1
-2
@@ -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,
|
||||
|
||||
+21
-9
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
+2
-1
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+27
-9
@@ -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!",
|
||||
|
||||
+22
-21
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
-2
@@ -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
|
||||
|
||||
+5
-7
@@ -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(),
|
||||
|
||||
+2
-3
@@ -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,
|
||||
|
||||
-2
@@ -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,
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+2
-1
@@ -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,
|
||||
),
|
||||
|
||||
+36
-17
@@ -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))
|
||||
|
||||
+114
-58
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-6
@@ -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,
|
||||
),
|
||||
|
||||
+5
-3
@@ -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(
|
||||
|
||||
-3
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+4
-11
@@ -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 = {},
|
||||
|
||||
+5
-7
@@ -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,
|
||||
|
||||
-3
@@ -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 = {},
|
||||
|
||||
+46
-3
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+9
-8
@@ -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(),
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+13
-17
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
||||
+3
-2
@@ -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
@@ -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() } },
|
||||
|
||||
+8
-4
@@ -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
|
||||
}
|
||||
|
||||
+8
-1
@@ -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
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
+6
-3
@@ -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(
|
||||
|
||||
+2
-2
@@ -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)
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
+4
-2
@@ -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,
|
||||
|
||||
+7
-3
@@ -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
|
||||
|
||||
+23
-27
@@ -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,
|
||||
|
||||
+14
-2
@@ -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
|
||||
}
|
||||
|
||||
+26
-20
@@ -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
|
||||
|
||||
+7
-3
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+13
-7
@@ -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,
|
||||
|
||||
+20
-10
@@ -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,
|
||||
),
|
||||
|
||||
+16
-11
@@ -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(
|
||||
|
||||
+11
-1
@@ -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">"Бібліятэка фота & відэа"</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 & 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 & 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 & 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">"여행 & 장소"</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 & 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>
|
||||
|
||||
+3
@@ -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 {
|
||||
|
||||
+5
@@ -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()
|
||||
}
|
||||
|
||||
+39
-2
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+126
-92
@@ -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(),
|
||||
|
||||
+2
-1
@@ -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(
|
||||
|
||||
+348
-5
@@ -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(
|
||||
|
||||
+78
@@ -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()
|
||||
}
|
||||
}
|
||||
+24
@@ -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
Reference in New Issue
Block a user