Merge branch 'develop' into feature/bma/fixSendQueueCrash

This commit is contained in:
ganfra 2024-11-22 10:51:41 +01:00
commit 0261739fff
281 changed files with 1877 additions and 998 deletions

View file

@ -1,3 +1,53 @@
Changes in Element X v0.7.4 (2024-11-20)
========================================
## What's Changed
### 🙌 Improvements
* Update the strings for unsupported calls by @bmarty in https://github.com/element-hq/element-x-android/pull/3857
### 🐛 Bugfixes
* Stop incoming call ringing if answered on another device. by @bmarty in https://github.com/element-hq/element-x-android/pull/3842
* Use formatted captions for images and video by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3864
* Fix unified push unregister by @bmarty in https://github.com/element-hq/element-x-android/pull/3877
* Hide the keyboard when navigating from the chat room screen by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3878
* Fix long click not working for media timeline items by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3879
* Instantiate the verification controller ASAP by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3893
* fix : display security banner for room list empty state by @ganfra in https://github.com/element-hq/element-x-android/pull/3892
### 🗣 Translations
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/3852
* Sync Strings - add translations to Finnish by @ElementBot in https://github.com/element-hq/element-x-android/pull/3883
### 🚧 In development 🚧
* Create room : improve handling of room address by @ganfra in https://github.com/element-hq/element-x-android/pull/3868
### Dependency upgrades
* Update anvil to v0.4.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3792
* Update kotlin to v2.0.21-1.0.27 by @renovate in https://github.com/element-hq/element-x-android/pull/3836
* Update dependency org.maplibre.gl:android-sdk to v11.6.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3793
* Update android.gradle.plugin to v8.7.2 by @renovate in https://github.com/element-hq/element-x-android/pull/3785
* Update lifecycle to v2.8.7 by @renovate in https://github.com/element-hq/element-x-android/pull/3763
* Update plugin dependencycheck to v11 by @renovate in https://github.com/element-hq/element-x-android/pull/3723
* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.61 by @renovate in https://github.com/element-hq/element-x-android/pull/3841
* Update mobile-dev-inc/action-maestro-cloud action to v1.9.6 by @renovate in https://github.com/element-hq/element-x-android/pull/3846
* Update dependency com.posthog:posthog-android to v3.9.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3856
* Update core to v1.15.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3766
* Update dependency com.android.tools:desugar_jdk_libs to v2.1.3 by @renovate in https://github.com/element-hq/element-x-android/pull/3825
* Update dependency io.nlopez.compose.rules:detekt to v0.4.18 by @renovate in https://github.com/element-hq/element-x-android/pull/3860
* Update dependency com.posthog:posthog-android to v3.9.2 by @renovate in https://github.com/element-hq/element-x-android/pull/3861
* Update dependency io.sentry:sentry-android to v7.17.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3862
* Update dependency androidx.compose:compose-bom to v2024.11.00 by @renovate in https://github.com/element-hq/element-x-android/pull/3869
* Update telephoto to v0.14.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3870
* Update SDK bindings version to `0.2.62` and fix `SendHandle` usages by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3876
* Update codecov/codecov-action action to v5 by @renovate in https://github.com/element-hq/element-x-android/pull/3874
* Update dependency com.google.firebase:firebase-bom to v33.6.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3880
* Update kotlin to v2.0.21-1.0.28 by @renovate in https://github.com/element-hq/element-x-android/pull/3881
* Update dependency org.robolectric:robolectric to v4.14 by @renovate in https://github.com/element-hq/element-x-android/pull/3882
* Update appyx to v1.5.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3889
* Update dependency io.nlopez.compose.rules:detekt to v0.4.19 by @renovate in https://github.com/element-hq/element-x-android/pull/3900
* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.63 by @renovate in https://github.com/element-hq/element-x-android/pull/3898
### Others
* Design system : implement new TextField by @ganfra in https://github.com/element-hq/element-x-android/pull/3834
* Remove :samples:minimal module by @bmarty in https://github.com/element-hq/element-x-android/pull/3871
* Replace `textPlaceholder` color usages with `textSecondary` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3873
* Room Preview API changes by @ganfra in https://github.com/element-hq/element-x-android/pull/3875
Changes in Element X v0.7.3 (2024-11-08)
========================================

View file

@ -87,7 +87,7 @@ class MainActivity : NodeActivity() {
@Composable
private fun MainNodeHost() {
NodeHost(integrationPoint = appyxIntegrationPoint) {
NodeHost(integrationPoint = appyxV1IntegrationPoint) {
MainNode(
it,
plugins = listOf(

View file

@ -48,9 +48,11 @@ import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.getRoomInfoFlow
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
@ -67,6 +69,7 @@ class RoomFlowNode @AssistedInject constructor(
private val joinRoomEntryPoint: JoinRoomEntryPoint,
private val roomAliasResolverEntryPoint: RoomAliasResolverEntryPoint,
private val networkMonitor: NetworkMonitor,
private val membershipObserver: RoomMembershipObserver,
) : BaseFlowNode<RoomFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Loading,
@ -145,10 +148,6 @@ class RoomFlowNode @AssistedInject constructor(
backstack.newRoot(NavTarget.JoinedRoom(roomId))
}
}
CurrentUserMembership.LEFT -> {
// Left the room, navigate out of this flow
navigateUp()
}
else -> {
// Was invited or the room is not known, display the join room screen
backstack.newRoot(
@ -161,6 +160,15 @@ class RoomFlowNode @AssistedInject constructor(
}
}
}.launchIn(lifecycleScope)
// If the user leaves the room from this client, close the room flow.
lifecycleScope.launch {
membershipObserver.updates
.first { it.roomId == roomId && !it.isUserInRoom }
.run {
navigateUp()
}
}
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {

View file

@ -49,7 +49,7 @@ allprojects {
config.from(files("$rootDir/tools/detekt/detekt.yml"))
}
dependencies {
detektPlugins("io.nlopez.compose.rules:detekt:0.4.18")
detektPlugins("io.nlopez.compose.rules:detekt:0.4.19")
}
// KtLint

View file

@ -43,14 +43,14 @@ class JoinRoomNode @AssistedInject constructor(
state = state,
onBackClick = ::navigateUp,
onJoinSuccess = ::navigateUp,
onCancelKnockSuccess = ::navigateUp,
onKnockSuccess = { },
onCancelKnockSuccess = {},
onKnockSuccess = {},
modifier = modifier
)
acceptDeclineInviteView.Render(
state = state.acceptDeclineInviteState,
onAcceptInvite = {},
onDeclineInvite = { navigateUp() },
onDeclineInvite = {},
modifier = Modifier
)
}

View file

@ -22,7 +22,6 @@ import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.isDm
import kotlinx.coroutines.launch
import timber.log.Timber
@ -30,7 +29,6 @@ import javax.inject.Inject
class LeaveRoomPresenter @Inject constructor(
private val client: MatrixClient,
private val roomMembershipObserver: RoomMembershipObserver,
private val dispatchers: CoroutineDispatchers,
) : Presenter<LeaveRoomState> {
@Composable
@ -58,7 +56,6 @@ class LeaveRoomPresenter @Inject constructor(
is LeaveRoomEvent.LeaveRoom -> scope.launch(dispatchers.io) {
client.leaveRoom(
roomId = event.roomId,
roomMembershipObserver = roomMembershipObserver,
confirmation = confirmation,
progress = progress,
error = error,
@ -88,7 +85,6 @@ private suspend fun showLeaveRoomAlert(
private suspend fun MatrixClient.leaveRoom(
roomId: RoomId,
roomMembershipObserver: RoomMembershipObserver,
confirmation: MutableState<LeaveRoomState.Confirmation>,
progress: MutableState<LeaveRoomState.Progress>,
error: MutableState<LeaveRoomState.Error>,
@ -96,12 +92,11 @@ private suspend fun MatrixClient.leaveRoom(
confirmation.value = LeaveRoomState.Confirmation.Hidden
progress.value = LeaveRoomState.Progress.Shown
getRoom(roomId)?.use { room ->
room.leave().onSuccess {
roomMembershipObserver.notifyUserLeftRoom(room.roomId)
}.onFailure {
Timber.e(it, "Error while leaving room ${room.displayName} - ${room.roomId}")
error.value = LeaveRoomState.Error.Shown
}
room.leave()
.onFailure {
Timber.e(it, "Error while leaving room ${room.roomId}")
error.value = LeaveRoomState.Error.Shown
}
}
progress.value = LeaveRoomState.Progress.Hidden
}

View file

@ -14,15 +14,15 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@ -126,26 +126,27 @@ class LeaveRoomPresenterTest {
@Test
fun `present - leaving a room leaves the room`() = runTest {
val roomMembershipObserver = RoomMembershipObserver()
val leaveRoomLambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val presenter = createLeaveRoomPresenter(
client = FakeMatrixClient().apply {
givenGetRoomResult(
roomId = A_ROOM_ID,
result = FakeMatrixRoom(
leaveRoomLambda = { Result.success(Unit) }
leaveRoomLambda = leaveRoomLambda
),
)
},
roomMembershipObserver = roomMembershipObserver
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID))
// Membership observer should receive a 'left room' change
assertThat(roomMembershipObserver.updates.first().change).isEqualTo(MembershipChange.LEFT)
advanceUntilIdle()
cancelAndIgnoreRemainingEvents()
assert(leaveRoomLambda)
.isCalledOnce()
.withNoParameter()
}
}
@ -227,9 +228,7 @@ class LeaveRoomPresenterTest {
private fun TestScope.createLeaveRoomPresenter(
client: MatrixClient = FakeMatrixClient(),
roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(),
): LeaveRoomPresenter = LeaveRoomPresenter(
client = client,
roomMembershipObserver = roomMembershipObserver,
dispatchers = testCoroutineDispatchers(false),
)

View file

@ -7,13 +7,16 @@
package io.element.android.features.messages.impl
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import kotlinx.collections.immutable.ImmutableList
interface MessagesNavigator {
fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo)
fun onForwardEventClick(eventId: EventId)
fun onReportContentClick(eventId: EventId, senderId: UserId)
fun onEditPollClick(eventId: EventId)
fun onPreviewAttachment(attachments: ImmutableList<Attachment>)
}

View file

@ -30,7 +30,9 @@ import io.element.android.anvilannotations.ContributesNode
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.model.TimelineItem
@ -60,12 +62,18 @@ class MessagesNode @AssistedInject constructor(
@Assisted plugins: List<Plugin>,
private val room: MatrixRoom,
private val analyticsService: AnalyticsService,
messageComposerPresenterFactory: MessageComposerPresenter.Factory,
timelinePresenterFactory: TimelinePresenter.Factory,
presenterFactory: MessagesPresenter.Factory,
private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
private val mediaPlayer: MediaPlayer,
private val permalinkParser: PermalinkParser,
) : Node(buildContext, plugins = plugins), MessagesNavigator {
private val presenter = presenterFactory.create(this)
private val presenter = presenterFactory.create(
navigator = this,
composerPresenter = messageComposerPresenterFactory.create(this),
timelinePresenter = timelinePresenterFactory.create(this),
)
private val callbacks = plugins<Callback>()
data class Inputs(val focusedEventId: EventId?) : NodeInputs
@ -114,10 +122,6 @@ class MessagesNode @AssistedInject constructor(
.orFalse()
}
private fun onPreviewAttachments(attachments: ImmutableList<Attachment>) {
callbacks.forEach { it.onPreviewAttachments(attachments) }
}
private fun onUserDataClick(userId: UserId) {
callbacks.forEach { it.onUserDataClick(userId) }
}
@ -178,6 +182,10 @@ class MessagesNode @AssistedInject constructor(
callbacks.forEach { it.onEditPollClick(eventId) }
}
override fun onPreviewAttachment(attachments: ImmutableList<Attachment>) {
callbacks.forEach { it.onPreviewAttachments(attachments) }
}
private fun onViewAllPinnedMessagesClick() {
callbacks.forEach { it.onViewAllPinnedEvents() }
}
@ -213,7 +221,6 @@ class MessagesNode @AssistedInject constructor(
onBackClick = this::navigateUp,
onRoomDetailsClick = this::onRoomDetailsClick,
onEventContentClick = this::onEventClick,
onPreviewAttachments = this::onPreviewAttachments,
onUserDataClick = this::onUserDataClick,
onLinkClick = { url -> onLinkClick(activity, isDark, url, state.timelineState.eventSink) },
onSendLocationClick = this::onSendLocationClick,

View file

@ -37,12 +37,12 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.messages.impl.timeline.TimelineState
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentWithAttachment
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
@ -88,12 +88,12 @@ import timber.log.Timber
class MessagesPresenter @AssistedInject constructor(
@Assisted private val navigator: MessagesNavigator,
private val room: MatrixRoom,
private val composerPresenter: Presenter<MessageComposerState>,
@Assisted private val composerPresenter: Presenter<MessageComposerState>,
private val voiceMessageComposerPresenter: Presenter<VoiceMessageComposerState>,
timelinePresenterFactory: TimelinePresenter.Factory,
@Assisted private val timelinePresenter: Presenter<TimelineState>,
private val timelineProtectionPresenter: Presenter<TimelineProtectionState>,
private val identityChangeStatePresenter: Presenter<IdentityChangeState>,
private val actionListPresenterFactory: ActionListPresenter.Factory,
actionListPresenterFactory: ActionListPresenter.Factory,
private val customReactionPresenter: Presenter<CustomReactionState>,
private val reactionSummaryPresenter: Presenter<ReactionSummaryState>,
private val readReceiptBottomSheetPresenter: Presenter<ReadReceiptBottomSheetState>,
@ -110,12 +110,15 @@ class MessagesPresenter @AssistedInject constructor(
private val permalinkParser: PermalinkParser,
private val analyticsService: AnalyticsService,
) : Presenter<MessagesState> {
private val timelinePresenter = timelinePresenterFactory.create(navigator = navigator)
private val actionListPresenter = actionListPresenterFactory.create(TimelineItemActionPostProcessor.Default)
@AssistedFactory
interface Factory {
fun create(navigator: MessagesNavigator): MessagesPresenter
fun create(
navigator: MessagesNavigator,
composerPresenter: Presenter<MessageComposerState>,
timelinePresenter: Presenter<TimelineState>,
): MessagesPresenter
}
@Composable
@ -273,6 +276,9 @@ class MessagesPresenter @AssistedInject constructor(
TimelineItemAction.CopyLink -> handleCopyLink(targetEvent)
TimelineItemAction.Redact -> handleActionRedact(targetEvent)
TimelineItemAction.Edit -> handleActionEdit(targetEvent, composerState, enableTextFormatting)
TimelineItemAction.AddCaption -> handleActionAddCaption(targetEvent, composerState)
TimelineItemAction.EditCaption -> handleActionEditCaption(targetEvent, composerState)
TimelineItemAction.RemoveCaption -> handleRemoveCaption(targetEvent)
TimelineItemAction.Reply,
TimelineItemAction.ReplyInThread -> handleActionReply(targetEvent, composerState, timelineProtectionState)
TimelineItemAction.ViewSource -> handleShowDebugInfoAction(targetEvent)
@ -285,6 +291,16 @@ class MessagesPresenter @AssistedInject constructor(
}
}
private suspend fun handleRemoveCaption(targetEvent: TimelineItem.Event) {
timelineController.invokeOnCurrentTimeline {
editCaption(
eventOrTransactionId = targetEvent.eventOrTransactionId,
caption = null,
formattedCaption = null,
)
}
}
private suspend fun handlePinAction(targetEvent: TimelineItem.Event) {
if (targetEvent.eventId == null) return
analyticsService.capture(
@ -387,6 +403,32 @@ class MessagesPresenter @AssistedInject constructor(
}
}
private fun handleActionAddCaption(
targetEvent: TimelineItem.Event,
composerState: MessageComposerState,
) {
val composerMode = MessageComposerMode.EditCaption(
eventOrTransactionId = targetEvent.eventOrTransactionId,
content = "",
)
composerState.eventSink(
MessageComposerEvents.SetMode(composerMode)
)
}
private fun handleActionEditCaption(
targetEvent: TimelineItem.Event,
composerState: MessageComposerState,
) {
val composerMode = MessageComposerMode.EditCaption(
eventOrTransactionId = targetEvent.eventOrTransactionId,
content = (targetEvent.content as? TimelineItemEventContentWithAttachment)?.caption.orEmpty(),
)
composerState.eventSink(
MessageComposerEvents.SetMode(composerMode)
)
}
private suspend fun handleActionReply(
targetEvent: TimelineItem.Event,
composerState: MessageComposerState,

View file

@ -12,7 +12,6 @@ import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.anActionListState
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
import io.element.android.features.messages.impl.crypto.identity.anIdentityChangeState
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
@ -62,16 +61,6 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
enableVoiceMessages = true,
voiceMessageComposerState = aVoiceMessageComposerState(showPermissionRationaleDialog = true),
),
aMessagesState(
composerState = aMessageComposerState(
attachmentsState = AttachmentsState.Sending.Processing(persistentListOf())
),
),
aMessagesState(
composerState = aMessageComposerState(
attachmentsState = AttachmentsState.Sending.Uploading(0.33f)
),
),
aMessagesState(
roomCallState = anOngoingCallState(),
),

View file

@ -32,10 +32,8 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -55,10 +53,8 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListView
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeStateView
import io.element.android.features.messages.impl.messagecomposer.AttachmentsBottomSheet
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
import io.element.android.features.messages.impl.messagecomposer.MessageComposerView
import io.element.android.features.messages.impl.messagecomposer.suggestions.SuggestionsPickerView
@ -83,8 +79,6 @@ import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorVi
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.androidutils.ui.hideKeyboard
import io.element.android.libraries.designsystem.atomic.molecules.IconTitlePlaceholdersRowMolecule
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.ProgressDialogType
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.CompositeAvatar
@ -117,7 +111,6 @@ fun MessagesView(
onEventContentClick: (event: TimelineItem.Event) -> Boolean,
onUserDataClick: (UserId) -> Unit,
onLinkClick: (String) -> Unit,
onPreviewAttachments: (ImmutableList<Attachment>) -> Unit,
onSendLocationClick: () -> Unit,
onCreatePollClick: () -> Unit,
onJoinCallClick: () -> Unit,
@ -131,12 +124,6 @@ fun MessagesView(
KeepScreenOn(state.voiceMessageComposerState.keepScreenOn)
AttachmentStateView(
state = state.composerState.attachmentsState,
onPreviewAttachments = onPreviewAttachments,
onCancel = { state.composerState.eventSink(MessageComposerEvents.CancelSendAttachment) },
)
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
// This is needed because the composer is inside an AndroidView that can't be affected by the FocusManager in Compose
@ -276,34 +263,6 @@ private fun ReinviteDialog(state: MessagesState) {
}
}
@Composable
private fun AttachmentStateView(
state: AttachmentsState,
onPreviewAttachments: (ImmutableList<Attachment>) -> Unit,
onCancel: () -> Unit,
) {
when (state) {
AttachmentsState.None -> Unit
is AttachmentsState.Previewing -> {
val latestOnPreviewAttachments by rememberUpdatedState(onPreviewAttachments)
LaunchedEffect(state) {
latestOnPreviewAttachments(state.attachments)
}
}
is AttachmentsState.Sending -> {
ProgressDialog(
type = when (state) {
is AttachmentsState.Sending.Uploading -> ProgressDialogType.Determinate(state.progress)
is AttachmentsState.Sending.Processing -> ProgressDialogType.Indeterminate
},
text = stringResource(id = CommonStrings.common_sending),
showCancelButton = true,
onDismissRequest = onCancel,
)
}
}
}
@Composable
private fun MessagesViewContent(
state: MessagesState,
@ -572,7 +531,6 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class)
onEventContentClick = { false },
onUserDataClick = {},
onLinkClick = {},
onPreviewAttachments = {},
onSendLocationClick = {},
onCreatePollClick = {},
onJoinCallClick = {},

View file

@ -27,6 +27,7 @@ import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUser
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemCallNotifyContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentWithAttachment
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
@ -153,7 +154,17 @@ class DefaultActionListPresenter @AssistedInject constructor(
add(TimelineItemAction.Forward)
}
if (timelineItem.isEditable) {
add(TimelineItemAction.Edit)
if (timelineItem.content is TimelineItemEventContentWithAttachment) {
// Caption
if (timelineItem.content.caption == null) {
add(TimelineItemAction.AddCaption)
} else {
add(TimelineItemAction.EditCaption)
add(TimelineItemAction.RemoveCaption)
}
} else {
add(TimelineItemAction.Edit)
}
}
if (canRedact && timelineItem.content is TimelineItemPollContent && !timelineItem.content.isEnded) {
add(TimelineItemAction.EndPoll)

View file

@ -28,6 +28,9 @@ sealed class TimelineItemAction(
data object Reply : TimelineItemAction(CommonStrings.action_reply, CompoundDrawables.ic_compound_reply)
data object ReplyInThread : TimelineItemAction(CommonStrings.action_reply_in_thread, CompoundDrawables.ic_compound_reply)
data object Edit : TimelineItemAction(CommonStrings.action_edit, CompoundDrawables.ic_compound_edit)
data object EditCaption : TimelineItemAction(CommonStrings.action_edit_caption, CompoundDrawables.ic_compound_edit)
data object AddCaption : TimelineItemAction(CommonStrings.action_add_caption, CompoundDrawables.ic_compound_edit)
data object RemoveCaption : TimelineItemAction(CommonStrings.action_remove_caption, CompoundDrawables.ic_compound_delete, destructive = true)
data object ViewSource : TimelineItemAction(CommonStrings.action_view_source, CommonDrawables.ic_developer_options)
data object ReportContent : TimelineItemAction(CommonStrings.action_report_content, CompoundDrawables.ic_compound_chat_problem, destructive = true)
data object EndPoll : TimelineItemAction(CommonStrings.action_end_poll, CompoundDrawables.ic_compound_polls_end)

View file

@ -9,9 +9,6 @@ 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.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.textcomposer.model.TextEditorState
data class AttachmentsPreviewState(
@ -20,9 +17,8 @@ data class AttachmentsPreviewState(
val textEditorState: TextEditorState,
val eventSink: (AttachmentsPreviewEvents) -> Unit
) {
val allowCaption: Boolean = (attachment as? Attachment.Media)?.localMedia?.info?.mimeType?.let {
it.isMimeTypeImage() || it.isMimeTypeVideo()
}.orFalse()
// Keep the val to eventually set to false for some mimetypes.
val allowCaption: Boolean = true
}
@Immutable

View file

@ -36,7 +36,6 @@ internal fun MessagesViewWithIdentityChangePreview(
onEventContentClick = { false },
onUserDataClick = {},
onLinkClick = {},
onPreviewAttachments = {},
onSendLocationClick = {},
onCreatePollClick = {},
onJoinCallClick = {},

View file

@ -14,8 +14,6 @@ import io.element.android.features.messages.impl.crypto.identity.IdentityChangeS
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeStatePresenter
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailurePresenter
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerPresenter
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter
@ -48,9 +46,6 @@ interface MessagesModule {
@Binds
fun bindTimelineProtectionPresenter(presenter: TimelineProtectionPresenter): Presenter<TimelineProtectionState>
@Binds
fun bindMessageComposerPresenter(presenter: MessageComposerPresenter): Presenter<MessageComposerState>
@Binds
fun bindVoiceMessageComposerPresenter(presenter: VoiceMessageComposerPresenter): Presenter<VoiceMessageComposerState>

View file

@ -31,7 +31,6 @@ sealed interface MessageComposerEvents {
data object Poll : PickAttachmentSource
}
data class ToggleTextFormatting(val enabled: Boolean) : MessageComposerEvents
data object CancelSendAttachment : MessageComposerEvents
data class Error(val error: Throwable) : MessageComposerEvents
data class TypingNotice(val isTyping: Boolean) : MessageComposerEvents
data class SuggestionReceived(val suggestion: Suggestion?) : MessageComposerEvents

View file

@ -14,7 +14,6 @@ import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
@ -26,8 +25,12 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.Composer
import im.vector.app.features.analytics.plan.Interaction
import io.element.android.features.messages.impl.MessagesNavigator
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.draft.ComposerDraftService
@ -38,11 +41,8 @@ import io.element.android.features.messages.impl.utils.TextPillificationHelper
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.ProgressCallback
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.PermalinkData
@ -81,7 +81,6 @@ import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
@ -89,16 +88,13 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
import kotlin.coroutines.coroutineContext
import kotlin.time.Duration.Companion.seconds
import io.element.android.libraries.core.mimetype.MimeTypes.Any as AnyMimeTypes
@SingleIn(RoomScope::class)
class MessageComposerPresenter @Inject constructor(
class MessageComposerPresenter @AssistedInject constructor(
@Assisted private val navigator: MessagesNavigator,
private val appCoroutineScope: CoroutineScope,
private val room: MatrixRoom,
private val mediaPickerProvider: PickerProvider,
@ -121,6 +117,11 @@ class MessageComposerPresenter @Inject constructor(
private val roomMemberProfilesCache: RoomMemberProfilesCache,
private val suggestionsProcessor: SuggestionsProcessor,
) : Presenter<MessageComposerState> {
@AssistedFactory
interface Factory {
fun create(navigator: MessagesNavigator): MessageComposerPresenter
}
private val cameraPermissionPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA)
private var pendingEvent: MessageComposerEvents? = null
private val suggestionSearchTrigger = MutableStateFlow<Suggestion?>(null)
@ -151,9 +152,6 @@ class MessageComposerPresenter @Inject constructor(
}
val cameraPermissionState = cameraPermissionPresenter.present()
val attachmentsState = remember {
mutableStateOf<AttachmentsState>(AttachmentsState.None)
}
val canShareLocation = remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
@ -166,40 +164,26 @@ class MessageComposerPresenter @Inject constructor(
}
val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker { uri, mimeType ->
handlePickedMedia(attachmentsState, uri, mimeType)
handlePickedMedia(uri, mimeType)
}
val filesPicker = mediaPickerProvider.registerFilePicker(AnyMimeTypes) { uri ->
handlePickedMedia(attachmentsState, uri)
handlePickedMedia(uri)
}
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri ->
handlePickedMedia(attachmentsState, uri, MimeTypes.IMAGE_JPEG)
handlePickedMedia(uri, MimeTypes.IMAGE_JPEG)
}
val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker { uri ->
handlePickedMedia(attachmentsState, uri, MimeTypes.VIDEO_MP4)
handlePickedMedia(uri, MimeTypes.VIDEO_MP4)
}
val isFullScreen = rememberSaveable {
mutableStateOf(false)
}
val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) }
var showAttachmentSourcePicker: Boolean by remember { mutableStateOf(false) }
val sendTypingNotifications by sessionPreferencesStore.isSendTypingNotificationsEnabled().collectAsState(initial = true)
val roomAliasSuggestions by roomAliasSuggestionsDataSource.getAllRoomAliasSuggestions().collectAsState(initial = emptyList())
LaunchedEffect(attachmentsState.value) {
when (val attachmentStateValue = attachmentsState.value) {
is AttachmentsState.Sending.Processing -> {
ongoingSendAttachmentJob.value = localCoroutineScope.sendAttachment(
attachmentStateValue.attachments.first(),
attachmentsState,
)
}
else -> Unit
}
}
LaunchedEffect(cameraPermissionState.permissionGranted) {
if (cameraPermissionState.permissionGranted) {
when (pendingEvent) {
@ -272,7 +256,7 @@ class MessageComposerPresenter @Inject constructor(
when (event) {
MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value
MessageComposerEvents.CloseSpecialMode -> {
if (messageComposerContext.composerMode is MessageComposerMode.Edit) {
if (messageComposerContext.composerMode.isEditing) {
localCoroutineScope.launch {
resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = true)
}
@ -295,7 +279,6 @@ class MessageComposerPresenter @Inject constructor(
formattedFileSize = null
),
),
attachmentState = attachmentsState,
)
is MessageComposerEvents.SetMode -> {
localCoroutineScope.setMode(event.composerMode, markdownTextEditorState, richTextEditorState)
@ -338,12 +321,6 @@ class MessageComposerPresenter @Inject constructor(
showAttachmentSourcePicker = false
// Navigation to the create poll screen is done at the view layer
}
is MessageComposerEvents.CancelSendAttachment -> {
ongoingSendAttachmentJob.value?.let {
it.cancel()
ongoingSendAttachmentJob.value == null
}
}
is MessageComposerEvents.ToggleTextFormatting -> {
showAttachmentSourcePicker = false
localCoroutineScope.toggleTextFormatting(event.enabled, markdownTextEditorState, richTextEditorState)
@ -420,7 +397,6 @@ class MessageComposerPresenter @Inject constructor(
showTextFormatting = showTextFormatting,
canShareLocation = canShareLocation.value,
canCreatePoll = canCreatePoll.value,
attachmentsState = attachmentsState.value,
suggestions = suggestions.toPersistentList(),
resolveMentionDisplay = resolveMentionDisplay,
eventSink = { handleEvents(it) },
@ -455,7 +431,15 @@ class MessageComposerPresenter @Inject constructor(
}
}
}
is MessageComposerMode.EditCaption -> {
timelineController.invokeOnCurrentTimeline {
editCaption(
capturedMode.eventOrTransactionId,
caption = message.markdown,
formattedCaption = message.html
)
}
}
is MessageComposerMode.Reply -> {
timelineController.invokeOnCurrentTimeline {
replyMessage(capturedMode.eventId, message.markdown, message.html, message.intentionalMentions)
@ -475,14 +459,12 @@ class MessageComposerPresenter @Inject constructor(
private fun CoroutineScope.sendAttachment(
attachment: Attachment,
attachmentState: MutableState<AttachmentsState>,
) = when (attachment) {
is Attachment.Media -> {
launch {
sendMedia(
uri = attachment.localMedia.uri,
mimeType = attachment.localMedia.info.mimeType,
attachmentState = attachmentState,
)
}
}
@ -490,14 +472,10 @@ class MessageComposerPresenter @Inject constructor(
@UnstableApi
private fun handlePickedMedia(
attachmentsState: MutableState<AttachmentsState>,
uri: Uri?,
mimeType: String? = null,
) {
if (uri == null) {
attachmentsState.value = AttachmentsState.None
return
}
uri ?: return
val localMedia = localMediaFactory.createFromUri(
uri = uri,
mimeType = mimeType,
@ -505,44 +483,21 @@ class MessageComposerPresenter @Inject constructor(
formattedFileSize = null
)
val mediaAttachment = Attachment.Media(localMedia)
val isPreviewable = when {
MimeTypes.isImage(localMedia.info.mimeType) -> true
MimeTypes.isVideo(localMedia.info.mimeType) -> true
MimeTypes.isAudio(localMedia.info.mimeType) -> true
else -> false
}
attachmentsState.value = if (isPreviewable) {
AttachmentsState.Previewing(persistentListOf(mediaAttachment))
} else {
AttachmentsState.Sending.Processing(persistentListOf(mediaAttachment))
}
navigator.onPreviewAttachment(persistentListOf(mediaAttachment))
}
private suspend fun sendMedia(
uri: Uri,
mimeType: String,
attachmentState: MutableState<AttachmentsState>,
) = runCatching {
val context = coroutineContext
val progressCallback = object : ProgressCallback {
override fun onProgress(current: Long, total: Long) {
if (context.isActive) {
attachmentState.value = AttachmentsState.Sending.Uploading(current.toFloat() / total.toFloat())
}
}
}
mediaSender.sendMedia(
uri = uri,
mimeType = mimeType,
progressCallback = progressCallback
progressCallback = null,
).getOrThrow()
}
.onSuccess {
attachmentState.value = AttachmentsState.None
}
.onFailure { cause ->
Timber.e(cause, "Failed to send attachment")
attachmentState.value = AttachmentsState.None
if (cause is CancellationException) {
throw cause
} else {
@ -612,6 +567,10 @@ class MessageComposerPresenter @Inject constructor(
mode.eventOrTransactionId.eventId?.let { eventId -> ComposerDraftType.Edit(eventId) }
}
is MessageComposerMode.Reply -> ComposerDraftType.Reply(mode.eventId)
is MessageComposerMode.EditCaption -> {
// TODO Need a new type to save caption in the SDK
null
}
}
return if (draftType == null || message.markdown.isBlank()) {
null
@ -686,7 +645,14 @@ class MessageComposerPresenter @Inject constructor(
val currentComposerMode = messageComposerContext.composerMode
when (newComposerMode) {
is MessageComposerMode.Edit -> {
if (currentComposerMode !is MessageComposerMode.Edit) {
if (currentComposerMode.isEditing.not()) {
val draft = createDraftFromState(markdownTextEditorState, richTextEditorState)
updateDraft(draft, isVolatile = true).join()
}
setText(newComposerMode.content, markdownTextEditorState, richTextEditorState)
}
is MessageComposerMode.EditCaption -> {
if (currentComposerMode.isEditing.not()) {
val draft = createDraftFromState(markdownTextEditorState, richTextEditorState)
updateDraft(draft, isVolatile = true).join()
}
@ -694,7 +660,7 @@ class MessageComposerPresenter @Inject constructor(
}
else -> {
// When coming from edit, just clear the composer as it'd be weird to reset a volatile draft in this scenario.
if (currentComposerMode is MessageComposerMode.Edit) {
if (currentComposerMode.isEditing) {
setText("", markdownTextEditorState, richTextEditorState)
}
}

View file

@ -7,9 +7,7 @@
package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.TextEditorState
@ -25,18 +23,7 @@ data class MessageComposerState(
val showTextFormatting: Boolean,
val canShareLocation: Boolean,
val canCreatePoll: Boolean,
val attachmentsState: AttachmentsState,
val suggestions: ImmutableList<ResolvedSuggestion>,
val resolveMentionDisplay: (String, String) -> TextDisplay,
val eventSink: (MessageComposerEvents) -> Unit,
)
@Immutable
sealed interface AttachmentsState {
data object None : AttachmentsState
data class Previewing(val attachments: ImmutableList<Attachment>) : AttachmentsState
sealed interface Sending : AttachmentsState {
data class Processing(val attachments: ImmutableList<Attachment>) : Sending
data class Uploading(val progress: Float) : Sending
}
}

View file

@ -31,7 +31,6 @@ fun aMessageComposerState(
showAttachmentSourcePicker: Boolean = false,
canShareLocation: Boolean = true,
canCreatePoll: Boolean = true,
attachmentsState: AttachmentsState = AttachmentsState.None,
suggestions: ImmutableList<ResolvedSuggestion> = persistentListOf(),
eventSink: (MessageComposerEvents) -> Unit = {},
) = MessageComposerState(
@ -42,7 +41,6 @@ fun aMessageComposerState(
showAttachmentSourcePicker = showAttachmentSourcePicker,
canShareLocation = canShareLocation,
canCreatePoll = canCreatePoll,
attachmentsState = attachmentsState,
suggestions = suggestions,
resolveMentionDisplay = { _, _ -> TextDisplay.Plain },
eventSink = eventSink,

View file

@ -265,6 +265,7 @@ private fun TimelineItemEventContentViewWrapper(
eventSink = { },
modifier = modifier,
onContentClick = onContentClick,
onLongClick = null,
onContentLayoutChange = onContentLayoutChange
)
}

View file

@ -30,7 +30,7 @@ internal fun ATimelineItemEventRow(
timelineProtectionState = timelineProtectionState,
isLastOutgoingMessage = isLastOutgoingMessage,
isHighlighted = isHighlighted,
onContentClick = {},
onEventClick = {},
onLongClick = {},
onLinkClick = {},
onUserDataClick = {},

View file

@ -72,6 +72,7 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.timeline.protection.mustBeProtected
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
import io.element.android.libraries.designsystem.components.EqualWidthColumn
import io.element.android.libraries.designsystem.components.avatar.Avatar
@ -114,7 +115,7 @@ fun TimelineItemEventRow(
renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
isHighlighted: Boolean,
onContentClick: () -> Unit,
onEventClick: () -> Unit,
onLongClick: () -> Unit,
onLinkClick: (String) -> Unit,
onUserDataClick: (UserId) -> Unit,
@ -127,10 +128,14 @@ fun TimelineItemEventRow(
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
modifier: Modifier = Modifier,
eventContentView: @Composable (Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit = { contentModifier, onContentLayoutChange ->
// Only pass down a custom clickable lambda if the content can be clicked separately
val onContentClick = onEventClick.takeUnless { event.isWholeContentClickable }
TimelineItemEventContentView(
content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId),
onContentClick = onContentClick,
onLongClick = onLongClick,
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onLinkClick = onLinkClick,
eventSink = eventSink,
@ -142,6 +147,13 @@ fun TimelineItemEventRow(
val coroutineScope = rememberCoroutineScope()
val interactionSource = remember { MutableInteractionSource() }
val onContentClick = if (event.mustBeProtected()) {
// In this case, let the content handle the click
{}
} else {
onEventClick
}
fun onUserDataClick() {
onUserDataClick(event.senderId)
}
@ -151,12 +163,6 @@ fun TimelineItemEventRow(
inReplyToClick(inReplyToEventId)
}
val onWholeItemClick = if (event.isWholeContentClickable) {
onContentClick
} else {
{}
}
Column(modifier = modifier.fillMaxWidth()) {
if (event.groupPosition.isNew()) {
Spacer(modifier = Modifier.height(16.dp))
@ -180,7 +186,7 @@ fun TimelineItemEventRow(
isHighlighted = isHighlighted,
timelineRoomInfo = timelineRoomInfo,
interactionSource = interactionSource,
onContentClick = onWholeItemClick,
onContentClick = onContentClick,
onLongClick = onLongClick,
inReplyToClick = ::inReplyToClick,
onUserDataClick = ::onUserDataClick,
@ -214,7 +220,7 @@ fun TimelineItemEventRow(
isHighlighted = isHighlighted,
timelineRoomInfo = timelineRoomInfo,
interactionSource = interactionSource,
onContentClick = onWholeItemClick,
onContentClick = onContentClick,
onLongClick = onLongClick,
inReplyToClick = ::inReplyToClick,
onUserDataClick = ::onUserDataClick,

View file

@ -61,7 +61,8 @@ fun TimelineItemGroupedEventsRow(
onLinkClick = onLinkClick,
eventSink = eventSink,
modifier = contentModifier,
onContentClick = {},
onContentClick = null,
onLongClick = null,
onContentLayoutChange = onContentLayoutChange
)
},
@ -126,7 +127,8 @@ private fun TimelineItemGroupedEventsRowContent(
onLinkClick = onLinkClick,
eventSink = eventSink,
modifier = contentModifier,
onContentClick = {},
onContentClick = null,
onLongClick = null,
onContentLayoutChange = onContentLayoutChange
)
},

View file

@ -61,6 +61,7 @@ internal fun TimelineItemRow(
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId),
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onContentClick = { onContentClick(event) },
onLongClick = { onLongClick(event) },
onLinkClick = onLinkClick,
eventSink = eventSink,
modifier = contentModifier,
@ -118,7 +119,7 @@ internal fun TimelineItemRow(
timelineProtectionState = timelineProtectionState,
isLastOutgoingMessage = isLastOutgoingMessage,
isHighlighted = timelineItem.isEvent(focusedEventId),
onContentClick = { onContentClick(timelineItem) },
onEventClick = { onContentClick(timelineItem) },
onLongClick = { onLongClick(timelineItem) },
onLinkClick = onLinkClick,
onUserDataClick = onUserDataClick,

View file

@ -74,7 +74,8 @@ fun TimelineItemStateEventRow(
hideMediaContent = false,
onShowContentClick = {},
eventSink = eventSink,
onContentClick = {},
onContentClick = null,
onLongClick = null,
modifier = Modifier.defaultTimelineContentPadding()
)
}

View file

@ -0,0 +1,128 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.foundation.background
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.libraries.designsystem.theme.components.Text
/**
* package-private, you should only use TimelineItemFileView and TimelineItemAudioView.
*/
@Composable
fun TimelineItemAttachmentView(
filename: String,
fileExtensionAndSize: String,
caption: String?,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier,
icon: (@Composable () -> Unit) = {},
) {
Column(
modifier = modifier,
) {
TimelineItemAttachmentHeaderView(
filename = filename,
fileExtensionAndSize = fileExtensionAndSize,
hasCaption = caption != null,
onContentLayoutChange = onContentLayoutChange,
icon = icon,
)
if (caption != null) {
TimelineItemAttachmentCaptionView(
modifier = Modifier.padding(top = 4.dp),
caption = caption,
onContentLayoutChange = onContentLayoutChange,
)
}
}
}
@Composable
private fun TimelineItemAttachmentHeaderView(
filename: String,
fileExtensionAndSize: String,
hasCaption: Boolean,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier,
icon: (@Composable () -> Unit),
) {
val iconSize = 32.dp
val spacing = 8.dp
Row(
modifier = modifier,
) {
Box(
modifier = Modifier
.size(iconSize)
.clip(CircleShape)
.background(ElementTheme.materialColors.background),
contentAlignment = Alignment.Center,
) {
icon()
}
Spacer(Modifier.width(spacing))
Column {
Text(
text = filename,
color = ElementTheme.materialColors.primary,
maxLines = 2,
style = ElementTheme.typography.fontBodyLgRegular,
overflow = TextOverflow.Ellipsis
)
Text(
text = fileExtensionAndSize,
color = ElementTheme.materialColors.secondary,
style = ElementTheme.typography.fontBodySmRegular,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
onTextLayout = if (hasCaption) {
{}
} else {
ContentAvoidingLayout.measureLastTextLine(
onContentLayoutChange = onContentLayoutChange,
extraWidth = iconSize + spacing
)
},
)
}
}
}
@Composable
private fun TimelineItemAttachmentCaptionView(
caption: String,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier,
) {
Text(
modifier = modifier,
text = caption,
color = ElementTheme.materialColors.primary,
style = ElementTheme.typography.fontBodyLgRegular,
onTextLayout = ContentAvoidingLayout.measureLastTextLine(
onContentLayoutChange = onContentLayoutChange,
)
)
}

View file

@ -7,32 +7,20 @@
package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.foundation.background
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.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.GraphicEq
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContentProvider
import io.element.android.libraries.designsystem.preview.ElementPreview
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.Text
@Composable
fun TimelineItemAudioView(
@ -40,18 +28,13 @@ fun TimelineItemAudioView(
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier,
) {
val iconSize = 32.dp
val spacing = 8.dp
Row(
TimelineItemAttachmentView(
filename = content.filename,
fileExtensionAndSize = content.fileExtensionAndSize,
caption = content.caption,
onContentLayoutChange = onContentLayoutChange,
modifier = modifier,
) {
Box(
modifier = Modifier
.size(iconSize)
.clip(CircleShape)
.background(ElementTheme.materialColors.background),
contentAlignment = Alignment.Center,
) {
icon = {
Icon(
imageVector = Icons.Outlined.GraphicEq,
contentDescription = null,
@ -60,28 +43,7 @@ fun TimelineItemAudioView(
.size(16.dp),
)
}
Spacer(Modifier.width(spacing))
Column {
Text(
text = content.bestDescription,
color = ElementTheme.materialColors.primary,
maxLines = 2,
style = ElementTheme.typography.fontBodyLgRegular,
overflow = TextOverflow.Ellipsis
)
Text(
text = content.fileExtensionAndSize,
color = ElementTheme.materialColors.secondary,
style = ElementTheme.typography.fontBodySmRegular,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
onTextLayout = ContentAvoidingLayout.measureLastTextLine(
onContentLayoutChange = onContentLayoutChange,
extraWidth = iconSize + spacing
)
)
}
}
)
}
@PreviewsDayNight

View file

@ -36,7 +36,8 @@ import io.element.android.libraries.architecture.Presenter
fun TimelineItemEventContentView(
content: TimelineItemEventContent,
hideMediaContent: Boolean,
onContentClick: () -> Unit,
onContentClick: (() -> Unit)?,
onLongClick: (() -> Unit)?,
onShowContentClick: () -> Unit,
onLinkClick: (url: String) -> Unit,
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
@ -68,13 +69,13 @@ fun TimelineItemEventContentView(
)
is TimelineItemLocationContent -> TimelineItemLocationView(
content = content,
onContentClick = onContentClick,
modifier = modifier
)
is TimelineItemImageContent -> TimelineItemImageView(
content = content,
hideMediaContent = hideMediaContent,
onContentClick = onContentClick,
onLongClick = onLongClick,
onShowContentClick = onShowContentClick,
onLinkClick = onLinkClick,
onContentLayoutChange = onContentLayoutChange,
@ -84,6 +85,7 @@ fun TimelineItemEventContentView(
content = content,
hideMediaContent = hideMediaContent,
onContentClick = onContentClick,
onLongClick = onLongClick,
onShowClick = onShowContentClick,
modifier = modifier,
)
@ -91,6 +93,7 @@ fun TimelineItemEventContentView(
content = content,
hideMediaContent = hideMediaContent,
onContentClick = onContentClick,
onLongClick = onLongClick,
onShowContentClick = onShowContentClick,
onLinkClick = onLinkClick,
onContentLayoutChange = onContentLayoutChange,

View file

@ -7,24 +7,13 @@
package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.foundation.background
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.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContentProvider
@ -32,7 +21,6 @@ import io.element.android.libraries.designsystem.icons.CompoundDrawables
import io.element.android.libraries.designsystem.preview.ElementPreview
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.Text
@Composable
fun TimelineItemFileView(
@ -40,18 +28,13 @@ fun TimelineItemFileView(
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier,
) {
val iconSize = 32.dp
val spacing = 8.dp
Row(
TimelineItemAttachmentView(
filename = content.filename,
fileExtensionAndSize = content.fileExtensionAndSize,
caption = content.caption,
onContentLayoutChange = onContentLayoutChange,
modifier = modifier,
) {
Box(
modifier = Modifier
.size(iconSize)
.clip(CircleShape)
.background(ElementTheme.materialColors.background),
contentAlignment = Alignment.Center,
) {
icon = {
Icon(
resourceId = CompoundDrawables.ic_compound_attachment,
contentDescription = null,
@ -61,28 +44,7 @@ fun TimelineItemFileView(
.rotate(-45f),
)
}
Spacer(Modifier.width(spacing))
Column {
Text(
text = content.bestDescription,
color = ElementTheme.materialColors.primary,
maxLines = 2,
style = ElementTheme.typography.fontBodyLgRegular,
overflow = TextOverflow.Ellipsis
)
Text(
text = content.fileExtensionAndSize,
color = ElementTheme.materialColors.secondary,
style = ElementTheme.typography.fontBodySmRegular,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
onTextLayout = ContentAvoidingLayout.measureLastTextLine(
onContentLayoutChange = onContentLayoutChange,
extraWidth = iconSize + spacing
)
)
}
}
)
}
@PreviewsDayNight

View file

@ -8,8 +8,9 @@
package io.element.android.features.messages.impl.timeline.components.event
import android.text.SpannedString
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@ -48,6 +49,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContentProvider
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.protection.ProtectedView
import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -55,11 +57,13 @@ import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.compose.EditorStyledText
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun TimelineItemImageView(
content: TimelineItemImageContent,
hideMediaContent: Boolean,
onContentClick: () -> Unit,
onContentClick: (() -> Unit)?,
onLongClick: (() -> Unit)?,
onLinkClick: (String) -> Unit,
onShowContentClick: () -> Unit,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
@ -76,7 +80,7 @@ fun TimelineItemImageView(
}
TimelineItemAspectRatioBox(
modifier = containerModifier.blurHashBackground(content.blurhash, alpha = 0.9f),
aspectRatio = content.aspectRatio,
aspectRatio = coerceRatioWhenHidingContent(content.aspectRatio, hideMediaContent),
) {
ProtectedView(
hideContent = hideMediaContent,
@ -87,7 +91,7 @@ fun TimelineItemImageView(
modifier = Modifier
.fillMaxWidth()
.then(if (isLoaded) Modifier.background(Color.White) else Modifier)
.clickable(onClick = onContentClick),
.then(if (onContentClick != null) Modifier.combinedClickable(onClick = onContentClick, onLongClick = onLongClick) else Modifier),
model = content.thumbnailMediaRequestData,
contentScale = ContentScale.Fit,
alignment = Alignment.Center,
@ -132,6 +136,7 @@ internal fun TimelineItemImageViewPreview(@PreviewParameter(TimelineItemImageCon
hideMediaContent = false,
onShowContentClick = {},
onContentClick = {},
onLongClick = {},
onLinkClick = {},
onContentLayoutChange = {},
)
@ -145,6 +150,7 @@ internal fun TimelineItemImageViewHideMediaContentPreview() = ElementPreview {
hideMediaContent = true,
onShowContentClick = {},
onContentClick = {},
onLongClick = {},
onLinkClick = {},
onContentLayoutChange = {},
)

View file

@ -7,7 +7,6 @@
package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
@ -26,10 +25,9 @@ import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun TimelineItemLocationView(
content: TimelineItemLocationContent,
onContentClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.clickable(onClick = onContentClick).fillMaxWidth()) {
Column(modifier = modifier.fillMaxWidth()) {
content.description?.let {
Text(
text = it,
@ -55,6 +53,5 @@ internal fun TimelineItemLocationViewPreview(@PreviewParameter(TimelineItemLocat
ElementPreview {
TimelineItemLocationView(
content = content,
onContentClick = {},
)
}

View file

@ -7,8 +7,9 @@
package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
@ -29,6 +30,7 @@ import coil.compose.AsyncImagePainter
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContentProvider
import io.element.android.features.messages.impl.timeline.protection.ProtectedView
import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -37,11 +39,13 @@ import io.element.android.libraries.ui.strings.CommonStrings
private const val STICKER_SIZE_IN_DP = 128
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun TimelineItemStickerView(
content: TimelineItemStickerContent,
hideMediaContent: Boolean,
onContentClick: () -> Unit,
onContentClick: (() -> Unit)?,
onLongClick: (() -> Unit)?,
onShowClick: () -> Unit,
modifier: Modifier = Modifier,
) {
@ -51,7 +55,7 @@ fun TimelineItemStickerView(
) {
TimelineItemAspectRatioBox(
modifier = Modifier.blurHashBackground(content.blurhash, alpha = 0.9f),
aspectRatio = content.aspectRatio,
aspectRatio = coerceRatioWhenHidingContent(content.aspectRatio, hideMediaContent),
minHeight = STICKER_SIZE_IN_DP,
maxHeight = STICKER_SIZE_IN_DP,
) {
@ -64,7 +68,7 @@ fun TimelineItemStickerView(
modifier = Modifier
.fillMaxSize()
.then(if (isLoaded) Modifier.background(Color.White) else Modifier)
.clickable(onClick = onContentClick),
.then(if (onContentClick != null) Modifier.combinedClickable(onClick = onContentClick, onLongClick = onLongClick) else Modifier),
model = MediaRequestData(
source = content.preferredMediaSource,
kind = MediaRequestData.Kind.File(
@ -89,6 +93,7 @@ internal fun TimelineItemStickerViewPreview(@PreviewParameter(TimelineItemSticke
content = content,
hideMediaContent = false,
onContentClick = {},
onLongClick = {},
onShowClick = {},
)
}

View file

@ -8,9 +8,10 @@
package io.element.android.features.messages.impl.timeline.components.event
import android.text.SpannedString
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@ -53,6 +54,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContentProvider
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.protection.ProtectedView
import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
import io.element.android.libraries.designsystem.modifiers.roundedBackground
import io.element.android.libraries.designsystem.preview.ElementPreview
@ -64,11 +66,13 @@ import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.compose.EditorStyledText
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun TimelineItemVideoView(
content: TimelineItemVideoContent,
hideMediaContent: Boolean,
onContentClick: () -> Unit,
onContentClick: (() -> Unit)?,
onLongClick: (() -> Unit)?,
onShowContentClick: () -> Unit,
onLinkClick: (String) -> Unit,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
@ -87,7 +91,7 @@ fun TimelineItemVideoView(
}
TimelineItemAspectRatioBox(
modifier = containerModifier.blurHashBackground(content.blurHash, alpha = 0.9f),
aspectRatio = content.aspectRatio,
aspectRatio = coerceRatioWhenHidingContent(content.aspectRatio, hideMediaContent),
contentAlignment = Alignment.Center,
) {
ProtectedView(
@ -99,7 +103,7 @@ fun TimelineItemVideoView(
modifier = Modifier
.fillMaxWidth()
.then(if (isLoaded) Modifier.background(Color.White) else Modifier)
.clickable(onClick = onContentClick),
.then(if (onContentClick != null) Modifier.combinedClickable(onClick = onContentClick, onLongClick = onLongClick) else Modifier),
model = MediaRequestData(
source = content.thumbnailSource,
kind = MediaRequestData.Kind.Thumbnail(
@ -161,6 +165,7 @@ internal fun TimelineItemVideoViewPreview(@PreviewParameter(TimelineItemVideoCon
hideMediaContent = false,
onShowContentClick = {},
onContentClick = {},
onLongClick = {},
onLinkClick = {},
onContentLayoutChange = {},
)
@ -174,6 +179,7 @@ internal fun TimelineItemVideoViewHideMediaContentPreview() = ElementPreview {
hideMediaContent = true,
onShowContentClick = {},
onContentClick = {},
onLongClick = {},
onLinkClick = {},
onContentLayoutChange = {},
)

View file

@ -87,6 +87,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
filename = messageType.filename,
caption = messageType.caption?.trimEnd(),
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
isEdited = content.isEdited,
mediaSource = messageType.source,
thumbnailSource = messageType.info?.thumbnailSource,
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
@ -106,6 +107,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
filename = messageType.filename,
caption = messageType.caption?.trimEnd(),
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
isEdited = content.isEdited,
mediaSource = messageType.source,
thumbnailSource = messageType.info?.thumbnailSource,
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
@ -143,6 +145,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
filename = messageType.filename,
caption = messageType.caption?.trimEnd(),
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
isEdited = content.isEdited,
thumbnailSource = messageType.info?.thumbnailSource,
videoSource = messageType.source,
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
@ -162,6 +165,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
filename = messageType.filename,
caption = messageType.caption?.trimEnd(),
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
isEdited = content.isEdited,
mediaSource = messageType.source,
duration = messageType.info?.duration ?: Duration.ZERO,
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
@ -177,6 +181,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
filename = messageType.filename,
caption = messageType.caption?.trimEnd(),
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
isEdited = content.isEdited,
mediaSource = messageType.source,
duration = messageType.info?.duration ?: Duration.ZERO,
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
@ -188,6 +193,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
filename = messageType.filename,
caption = messageType.caption?.trimEnd(),
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
isEdited = content.isEdited,
mediaSource = messageType.source,
duration = messageType.info?.duration ?: Duration.ZERO,
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
@ -203,6 +209,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
filename = messageType.filename,
caption = messageType.caption?.trimEnd(),
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
isEdited = content.isEdited,
thumbnailSource = messageType.info?.thumbnailSource,
fileSource = messageType.source,
mimeType = messageType.info?.mimetype ?: MimeTypes.fromFileExtension(fileExtension),

View file

@ -36,6 +36,7 @@ class TimelineItemContentStickerFactory @Inject constructor(
filename = content.filename,
caption = content.body,
formattedCaption = null,
isEdited = false,
mediaSource = content.source,
thumbnailSource = content.info.thumbnailSource,
mimeType = content.info.mimetype ?: MimeTypes.OctetStream,

View file

@ -15,6 +15,7 @@ data class TimelineItemAudioContent(
override val filename: String,
override val caption: String?,
override val formattedCaption: CharSequence?,
override val isEdited: Boolean,
val duration: Duration,
val mediaSource: MediaSource,
val mimeType: String,

View file

@ -17,14 +17,20 @@ open class TimelineItemAudioContentProvider : PreviewParameterProvider<TimelineI
get() = sequenceOf(
aTimelineItemAudioContent("A sound.mp3"),
aTimelineItemAudioContent("A bigger name sound.mp3"),
aTimelineItemAudioContent("An even bigger bigger bigger bigger bigger bigger bigger sound name which doesn't fit .mp3"),
aTimelineItemAudioContent("An even bigger bigger bigger bigger bigger bigger bigger sound name which doesn't fit.mp3"),
aTimelineItemAudioContent(caption = "A caption"),
aTimelineItemAudioContent(caption = "An even bigger bigger bigger bigger bigger bigger bigger caption"),
)
}
fun aTimelineItemAudioContent(fileName: String = "A sound.mp3") = TimelineItemAudioContent(
fun aTimelineItemAudioContent(
fileName: String = "A sound.mp3",
caption: String? = null,
) = TimelineItemAudioContent(
filename = fileName,
caption = null,
caption = caption,
formattedCaption = null,
isEdited = false,
mimeType = MimeTypes.Mp3,
formattedFileSize = "100kB",
fileExtension = "mp3",

View file

@ -14,8 +14,15 @@ sealed interface TimelineItemEventContent {
val type: String
}
interface TimelineItemEventMutableContent {
/** Whether the event has been edited. */
val isEdited: Boolean
}
@Immutable
sealed interface TimelineItemEventContentWithAttachment : TimelineItemEventContent {
sealed interface TimelineItemEventContentWithAttachment :
TimelineItemEventContent,
TimelineItemEventMutableContent {
val filename: String
val caption: String?
val formattedCaption: CharSequence?
@ -74,9 +81,7 @@ fun TimelineItemEventContent.canReact(): Boolean =
/**
* Whether the event content has been edited.
*/
fun TimelineItemEventContent.isEdited(): Boolean =
when (this) {
is TimelineItemTextBasedContent -> isEdited
is TimelineItemPollContent -> isEdited
else -> false
}
fun TimelineItemEventContent.isEdited(): Boolean = when (this) {
is TimelineItemEventMutableContent -> isEdited
else -> false
}

View file

@ -14,6 +14,7 @@ data class TimelineItemFileContent(
override val filename: String,
override val caption: String?,
override val formattedCaption: CharSequence?,
override val isEdited: Boolean,
val fileSource: MediaSource,
val thumbnailSource: MediaSource?,
val formattedFileSize: String,

View file

@ -16,16 +16,20 @@ open class TimelineItemFileContentProvider : PreviewParameterProvider<TimelineIt
get() = sequenceOf(
aTimelineItemFileContent(),
aTimelineItemFileContent("A bigger name file.pdf"),
aTimelineItemFileContent("An even bigger bigger bigger bigger bigger bigger bigger file name which doesn't fit .pdf"),
aTimelineItemFileContent("An even bigger bigger bigger bigger bigger bigger bigger file name which doesn't fit.pdf"),
aTimelineItemFileContent(caption = "A caption"),
aTimelineItemFileContent(caption = "An even bigger bigger bigger bigger bigger bigger bigger caption"),
)
}
fun aTimelineItemFileContent(
fileName: String = "A file.pdf",
caption: String? = null,
) = TimelineItemFileContent(
filename = fileName,
caption = null,
caption = caption,
formattedCaption = null,
isEdited = false,
thumbnailSource = null,
fileSource = MediaSource(url = ""),
mimeType = MimeTypes.Pdf,

View file

@ -17,6 +17,7 @@ data class TimelineItemImageContent(
override val filename: String,
override val caption: String?,
override val formattedCaption: CharSequence?,
override val isEdited: Boolean,
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val formattedFileSize: String,

View file

@ -31,6 +31,7 @@ fun aTimelineItemImageContent(
filename = filename,
caption = caption,
formattedCaption = null,
isEdited = false,
mediaSource = MediaSource(""),
thumbnailSource = null,
mimeType = MimeTypes.IMAGE_JPEG,

View file

@ -19,7 +19,8 @@ data class TimelineItemPollContent(
val answerItems: List<PollAnswerItem>,
val pollKind: PollKind,
val isEnded: Boolean,
val isEdited: Boolean
) : TimelineItemEventContent {
override val isEdited: Boolean,
) : TimelineItemEventContent,
TimelineItemEventMutableContent {
override val type: String = "TimelineItemPollContent"
}

View file

@ -13,6 +13,7 @@ data class TimelineItemStickerContent(
override val filename: String,
override val caption: String?,
override val formattedCaption: CharSequence?,
override val isEdited: Boolean,
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val formattedFileSize: String,

View file

@ -29,6 +29,7 @@ fun aTimelineItemStickerContent(
filename = "a sticker.gif",
caption = "a body",
formattedCaption = null,
isEdited = false,
mediaSource = MediaSource(""),
thumbnailSource = null,
mimeType = MimeTypes.IMAGE_JPEG,

View file

@ -14,7 +14,9 @@ import org.jsoup.nodes.Document
* Represents a text based content of a timeline item event (a message, a notice, an emote event...).
*/
@Immutable
sealed interface TimelineItemTextBasedContent : TimelineItemEventContent {
sealed interface TimelineItemTextBasedContent :
TimelineItemEventContent,
TimelineItemEventMutableContent {
/** The raw body of the event, in Markdown format. */
val body: String
@ -30,9 +32,6 @@ sealed interface TimelineItemTextBasedContent : TimelineItemEventContent {
/** The plain text version of the event body. This is the Markdown version without actual Markdown formatting. */
val plainText: String
/** Whether the event has been edited. */
val isEdited: Boolean
/** The raw HTML body of the event. */
val htmlBody: String?
get() = htmlDocument?.body()?.html()

View file

@ -14,6 +14,7 @@ data class TimelineItemVideoContent(
override val filename: String,
override val caption: String?,
override val formattedCaption: CharSequence?,
override val isEdited: Boolean,
val duration: Duration,
val videoSource: MediaSource,
val thumbnailSource: MediaSource?,

View file

@ -30,6 +30,7 @@ fun aTimelineItemVideoContent(
filename = "Video.mp4",
caption = null,
formattedCaption = null,
isEdited = false,
thumbnailSource = null,
blurHash = blurhash,
aspectRatio = aspectRatio,

View file

@ -17,6 +17,7 @@ data class TimelineItemVoiceContent(
override val filename: String,
override val caption: String?,
override val formattedCaption: CharSequence?,
override val isEdited: Boolean,
val duration: Duration,
val mediaSource: MediaSource,
val mimeType: String,

View file

@ -48,6 +48,7 @@ fun aTimelineItemVoiceContent(
filename = filename,
caption = caption,
formattedCaption = null,
isEdited = false,
duration = duration,
mediaSource = mediaSource,
mimeType = mimeType,

View file

@ -0,0 +1,19 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.protection
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
class AspectRatioProvider : PreviewParameterProvider<Float?> {
override val values: Sequence<Float?> = sequenceOf(
null,
0.05f,
1f,
20f,
)
}

View file

@ -13,7 +13,6 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
@ -23,8 +22,10 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemAspectRatioBox
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -79,11 +80,12 @@ fun ProtectedView(
@PreviewsDayNight
@Composable
internal fun ProtectedViewPreview() = ElementPreview {
Box(
modifier = Modifier
.size(160.dp)
.blurHashBackground(A_BLUR_HASH)
internal fun ProtectedViewPreview(
@PreviewParameter(AspectRatioProvider::class) aspectRatio: Float?,
) = ElementPreview {
TimelineItemAspectRatioBox(
modifier = Modifier.blurHashBackground(A_BLUR_HASH, alpha = 0.9f),
aspectRatio = coerceRatioWhenHidingContent(aspectRatio, true),
) {
ProtectedView(
hideContent = true,

View file

@ -0,0 +1,19 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.protection
fun coerceRatioWhenHidingContent(aspectRatio: Float?, hideContent: Boolean): Float? {
return if (hideContent) {
aspectRatio?.coerceIn(
minimumValue = 0.5f,
maximumValue = 3f
)
} else {
aspectRatio
}
}

View file

@ -23,8 +23,6 @@ import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.messages.api.MessageComposerContext
import io.element.android.features.messages.impl.voicemessages.VoiceMessageException
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.permissions.api.PermissionsEvents
import io.element.android.libraries.permissions.api.PermissionsPresenter
@ -45,7 +43,6 @@ import javax.inject.Inject
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
@SingleIn(RoomScope::class)
class VoiceMessageComposerPresenter @Inject constructor(
private val appCoroutineScope: CoroutineScope,
private val voiceRecorder: VoiceRecorder,

View file

@ -7,36 +7,37 @@
package io.element.android.features.messages.impl
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.tests.testutils.lambda.lambdaError
import kotlinx.collections.immutable.ImmutableList
class FakeMessagesNavigator : MessagesNavigator {
var onShowEventDebugInfoClickedCount = 0
private set
var onForwardEventClickedCount = 0
private set
var onReportContentClickedCount = 0
private set
var onEditPollClickedCount = 0
private set
class FakeMessagesNavigator(
private val onShowEventDebugInfoClickLambda: (eventId: EventId?, debugInfo: TimelineItemDebugInfo) -> Unit = { _, _ -> lambdaError() },
private val onForwardEventClickLambda: (eventId: EventId) -> Unit = { _ -> lambdaError() },
private val onReportContentClickLambda: (eventId: EventId, senderId: UserId) -> Unit = { _, _ -> lambdaError() },
private val onEditPollClickLambda: (eventId: EventId) -> Unit = { _ -> lambdaError() },
private val onPreviewAttachmentLambda: (attachments: ImmutableList<Attachment>) -> Unit = { _ -> lambdaError() },
) : MessagesNavigator {
override fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
onShowEventDebugInfoClickedCount++
onShowEventDebugInfoClickLambda(eventId, debugInfo)
}
override fun onForwardEventClick(eventId: EventId) {
onForwardEventClickedCount++
onForwardEventClickLambda(eventId)
}
override fun onReportContentClick(eventId: EventId, senderId: UserId) {
onReportContentClickedCount++
onReportContentClickLambda(eventId, senderId)
}
override fun onEditPollClick(eventId: EventId) {
onEditPollClickedCount++
onEditPollClickLambda(eventId)
}
override fun onPreviewAttachment(attachments: ImmutableList<Attachment>) {
onPreviewAttachmentLambda(attachments)
}
}

View file

@ -23,20 +23,19 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
import io.element.android.features.messages.impl.pinned.banner.aLoadedPinnedMessagesBannerState
import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.messages.impl.timeline.createTimelinePresenter
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.aTimelineState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessageComposerState
import io.element.android.features.messages.test.timeline.FakeHtmlConverterProvider
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.features.poll.api.actions.EndPollAction
import io.element.android.features.poll.test.actions.FakeEndPollAction
import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
import io.element.android.libraries.architecture.AsyncData
@ -55,20 +54,24 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_CAPTION
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.matrix.test.timeline.aTimelineItemDebugInfo
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.TextEditorState
@ -82,6 +85,7 @@ import io.element.android.tests.testutils.lambda.assert
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.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -214,7 +218,10 @@ class MessagesPresenterTest {
@Test
fun `present - handle action forward`() = runTest {
val navigator = FakeMessagesNavigator()
val onForwardEventClickLambda = lambdaRecorder<EventId, Unit> { }
val navigator = FakeMessagesNavigator(
onForwardEventClickLambda = onForwardEventClickLambda,
)
val presenter = createMessagesPresenter(navigator = navigator)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@ -222,7 +229,7 @@ class MessagesPresenterTest {
val initialState = awaitItem()
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Forward, aMessageEvent()))
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
assertThat(navigator.onForwardEventClickedCount).isEqualTo(1)
onForwardEventClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID))
}
}
@ -318,6 +325,7 @@ class MessagesPresenterTest {
filename = "image.jpg",
caption = null,
formattedCaption = null,
isEdited = false,
mediaSource = MediaSource(AN_AVATAR_URL),
thumbnailSource = null,
mimeType = MimeTypes.Jpeg,
@ -359,6 +367,7 @@ class MessagesPresenterTest {
filename = "video.mp4",
caption = null,
formattedCaption = null,
isEdited = false,
duration = 10.milliseconds,
videoSource = MediaSource(AN_AVATAR_URL),
thumbnailSource = MediaSource(AN_AVATAR_URL),
@ -400,6 +409,7 @@ class MessagesPresenterTest {
content = TimelineItemFileContent(
filename = "file.pdf",
caption = null,
isEdited = false,
formattedCaption = null,
fileSource = MediaSource(AN_AVATAR_URL),
thumbnailSource = MediaSource(AN_AVATAR_URL),
@ -446,7 +456,10 @@ class MessagesPresenterTest {
@Test
fun `present - handle action edit poll`() = runTest {
val navigator = FakeMessagesNavigator()
val onEditPollClickLambda = lambdaRecorder<EventId, Unit> { }
val navigator = FakeMessagesNavigator(
onEditPollClickLambda = onEditPollClickLambda
)
val presenter = createMessagesPresenter(navigator = navigator)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@ -454,22 +467,21 @@ class MessagesPresenterTest {
val initialState = awaitItem()
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Edit, aMessageEvent(content = aTimelineItemPollContent())))
awaitItem()
assertThat(navigator.onEditPollClickedCount).isEqualTo(1)
onEditPollClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID))
}
}
@Test
fun `present - handle action end poll`() = runTest {
val endPollAction = FakeEndPollAction()
val presenter = createMessagesPresenter(endPollAction = endPollAction)
val timelineEventSink = EventsRecorder<TimelineEvents>()
val presenter = createMessagesPresenter(timelineEventSink = timelineEventSink)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
endPollAction.verifyExecutionCount(0)
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.EndPoll, aMessageEvent(content = aTimelineItemPollContent())))
delay(1)
endPollAction.verifyExecutionCount(1)
timelineEventSink.assertSingle(TimelineEvents.EndPoll(AN_EVENT_ID))
cancelAndIgnoreRemainingEvents()
}
}
@ -510,7 +522,10 @@ class MessagesPresenterTest {
@Test
fun `present - handle action report content`() = runTest {
val navigator = FakeMessagesNavigator()
val onReportContentClickLambda = lambdaRecorder { _: EventId, _: UserId -> }
val navigator = FakeMessagesNavigator(
onReportContentClickLambda = onReportContentClickLambda
)
val presenter = createMessagesPresenter(navigator = navigator)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@ -518,7 +533,7 @@ class MessagesPresenterTest {
val initialState = awaitItem()
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.ReportContent, aMessageEvent()))
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
assertThat(navigator.onReportContentClickedCount).isEqualTo(1)
onReportContentClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID), value(A_USER_ID))
}
}
@ -536,7 +551,10 @@ class MessagesPresenterTest {
@Test
fun `present - handle action show developer info`() = runTest {
val navigator = FakeMessagesNavigator()
val onShowEventDebugInfoClickLambda = lambdaRecorder { _: EventId?, _: TimelineItemDebugInfo -> }
val navigator = FakeMessagesNavigator(
onShowEventDebugInfoClickLambda = onShowEventDebugInfoClickLambda
)
val presenter = createMessagesPresenter(navigator = navigator)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@ -544,7 +562,7 @@ class MessagesPresenterTest {
val initialState = awaitItem()
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.ViewSource, aMessageEvent()))
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
assertThat(navigator.onShowEventDebugInfoClickedCount).isEqualTo(1)
onShowEventDebugInfoClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID), value(aTimelineItemDebugInfo()))
}
}
@ -969,6 +987,103 @@ class MessagesPresenterTest {
}
}
@Test
fun `present - handle action edit caption`() = runTest {
val messageEvent = aMessageEvent(
content = aTimelineItemImageContent(
caption = A_CAPTION,
)
)
val composerRecorder = EventsRecorder<MessageComposerEvents>()
val presenter = createMessagesPresenter(
messageComposerPresenter = { aMessageComposerState(eventSink = composerRecorder) },
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.EditCaption, messageEvent))
awaitItem()
composerRecorder.assertSingle(
MessageComposerEvents.SetMode(
composerMode = MessageComposerMode.EditCaption(
eventOrTransactionId = AN_EVENT_ID.toEventOrTransactionId(),
content = A_CAPTION,
)
)
)
}
}
@Test
fun `present - handle action add caption`() = runTest {
val composerRecorder = EventsRecorder<MessageComposerEvents>()
val presenter = createMessagesPresenter(
messageComposerPresenter = { aMessageComposerState(eventSink = composerRecorder) },
)
val messageEvent = aMessageEvent(
content = aTimelineItemImageContent(
caption = null,
)
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.AddCaption, messageEvent))
awaitItem()
composerRecorder.assertSingle(
MessageComposerEvents.SetMode(
composerMode = MessageComposerMode.EditCaption(
eventOrTransactionId = AN_EVENT_ID.toEventOrTransactionId(),
content = "",
)
)
)
}
}
@Test
fun `present - handle action remove caption`() = runTest {
val messageEvent = aMessageEvent(
content = aTimelineItemImageContent(
caption = A_CAPTION,
)
)
val editCaptionLambda = lambdaRecorder { _: EventOrTransactionId, _: String?, _: String? -> Result.success(Unit) }
val timeline = FakeTimeline().apply {
this.editCaptionLambda = editCaptionLambda
}
val room = FakeMatrixRoom(
liveTimeline = timeline,
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) },
canUserPinUnpinResult = { Result.success(true) },
)
val presenter = createMessagesPresenter(
matrixRoom = room,
)
presenter.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.RemoveCaption, messageEvent))
editCaptionLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID.toEventOrTransactionId()), value(null), value(null))
}
}
@Test
fun `present - handle action view in timeline, it should have no effect`() = runTest {
val messageEvent = aMessageEvent(
content = aTimelineItemTextContent()
)
val presenter = createMessagesPresenter()
presenter.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.ViewInTimeline, messageEvent))
// No op!
}
}
private fun TestScope.createMessagesPresenter(
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
matrixRoom: MatrixRoom = FakeMatrixRoom(
@ -984,7 +1099,7 @@ class MessagesPresenterTest {
navigator: FakeMessagesNavigator = FakeMessagesNavigator(),
clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(),
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
endPollAction: EndPollAction = FakeEndPollAction(),
timelineEventSink: (TimelineEvents) -> Unit = {},
permalinkParser: PermalinkParser = FakePermalinkParser(),
messageComposerPresenter: Presenter<MessageComposerState> = Presenter {
aMessageComposerState(
@ -994,19 +1109,12 @@ class MessagesPresenterTest {
},
actionListEventSink: (ActionListEvents) -> Unit = {},
): MessagesPresenter {
val timelinePresenterFactory = object : TimelinePresenter.Factory {
override fun create(navigator: MessagesNavigator): TimelinePresenter {
return createTimelinePresenter(
endPollAction = endPollAction,
)
}
}
val featureFlagService = FakeFeatureFlagService()
return MessagesPresenter(
room = matrixRoom,
composerPresenter = messageComposerPresenter,
voiceMessageComposerPresenter = { aVoiceMessageComposerState() },
timelinePresenterFactory = timelinePresenterFactory,
timelinePresenter = { aTimelineState(eventSink = timelineEventSink) },
timelineProtectionPresenter = { aTimelineProtectionState() },
actionListPresenterFactory = FakeActionListPresenter.Factory(actionListEventSink),
customReactionPresenter = { aCustomReactionState() },

View file

@ -33,7 +33,6 @@ import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.anActionListState
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.aChangedIdentitySendFailure
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
@ -64,7 +63,6 @@ import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.pressBack
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import org.junit.Rule
import org.junit.Test
@ -514,7 +512,6 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessa
onEventClick: (event: TimelineItem.Event) -> Boolean = EnsureNeverCalledWithParamAndResult(),
onUserDataClick: (UserId) -> Unit = EnsureNeverCalledWithParam(),
onLinkClick: (String) -> Unit = EnsureNeverCalledWithParam(),
onPreviewAttachments: (ImmutableList<Attachment>) -> Unit = EnsureNeverCalledWithParam(),
onSendLocationClick: () -> Unit = EnsureNeverCalled(),
onCreatePollClick: () -> Unit = EnsureNeverCalled(),
onJoinCallClick: () -> Unit = EnsureNeverCalled(),
@ -532,7 +529,6 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessa
onEventContentClick = onEventClick,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
onPreviewAttachments = onPreviewAttachments,
onSendLocationClick = onSendLocationClick,
onCreatePollClick = onCreatePollClick,
onJoinCallClick = onJoinCallClick,

View file

@ -29,12 +29,14 @@ import io.element.android.features.poll.api.pollcontent.aPollAnswerItemList
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_CAPTION
import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.runTest
import org.junit.Rule
@ -184,6 +186,51 @@ class ActionListPresenterTest {
}
}
@Test
fun `present - compute for others message in a thread`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
presenter.test {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = false,
isEditable = false,
isThreaded = true,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
)
initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = messageEvent,
userEventPermissions = aUserEventPermissions(
canRedactOwn = false,
canRedactOther = false,
canSendMessage = true,
canSendReaction = true,
canPinUnpin = true,
)
)
)
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.ReplyInThread,
TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.Copy,
TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent,
)
)
)
initialState.eventSink.invoke(ActionListEvents.Clear)
assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
}
}
@Test
fun `present - compute for others message cannot sent message`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
@ -373,6 +420,51 @@ class ActionListPresenterTest {
}
}
@Test
fun `present - compute for my message in a thread`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
presenter.test {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
isThreaded = true,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
)
initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = messageEvent,
userEventPermissions = aUserEventPermissions(
canRedactOwn = true,
canRedactOther = false,
canSendMessage = true,
canSendReaction = true,
canPinUnpin = true,
)
)
)
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.ReplyInThread,
TimelineItemAction.Forward,
TimelineItemAction.Edit,
TimelineItemAction.Pin,
TimelineItemAction.Copy,
TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource,
TimelineItemAction.Redact,
)
)
)
initialState.eventSink.invoke(ActionListEvents.Clear)
assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
}
}
@Test
fun `present - compute for my message cannot redact`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
@ -429,7 +521,7 @@ class ActionListPresenterTest {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
isEditable = false,
isEditable = true,
content = aTimelineItemImageContent(),
)
initialState.eventSink.invoke(
@ -444,8 +536,6 @@ class ActionListPresenterTest {
),
)
)
// val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
@ -455,6 +545,56 @@ class ActionListPresenterTest {
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.AddCaption,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource,
TimelineItemAction.Redact,
)
)
)
initialState.eventSink.invoke(ActionListEvents.Clear)
assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
}
}
@Test
fun `present - compute for a media with caption item`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
isEditable = true,
content = aTimelineItemImageContent(
caption = A_CAPTION,
),
)
initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = messageEvent,
userEventPermissions = aUserEventPermissions(
canRedactOwn = true,
canRedactOther = false,
canSendMessage = true,
canSendReaction = true,
canPinUnpin = true,
),
)
)
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.EditCaption,
TimelineItemAction.RemoveCaption,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource,

View file

@ -21,6 +21,7 @@ import io.element.android.features.messages.impl.attachments.preview.SendActionS
import io.element.android.features.messages.impl.fixtures.aMediaAttachment
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.media.AudioInfo
import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.VideoInfo
@ -41,6 +42,7 @@ import io.element.android.tests.testutils.fake.FakeTemporaryUriDeleter
import io.element.android.tests.testutils.lambda.any
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
@ -60,7 +62,7 @@ class AttachmentsPreviewPresenterTest {
@Test
fun `present - send media success scenario`() = runTest {
val sendFileResult = lambdaRecorder<File, FileInfo, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _ ->
val sendFileResult = lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
val room = FakeMatrixRoom(
@ -189,10 +191,46 @@ class AttachmentsPreviewPresenterTest {
}
}
@Test
fun `present - send audio with caption success scenario`() = runTest {
val sendAudioResult =
lambdaRecorder<File, AudioInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
val mediaPreProcessor = FakeMediaPreProcessor().apply {
givenAudioResult()
}
val room = FakeMatrixRoom(
sendAudioResult = sendAudioResult,
)
val onDoneListener = lambdaRecorder<Unit> { }
val presenter = createAttachmentsPreviewPresenter(
room = room,
mediaPreProcessor = mediaPreProcessor,
onDoneListener = { onDoneListener() },
)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
initialState.textEditorState.setMarkdown(A_CAPTION)
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
advanceUntilIdle()
sendAudioResult.assertions().isCalledOnce().with(
any(),
any(),
value(A_CAPTION),
any(),
any(),
)
onDoneListener.assertions().isCalledOnce()
}
}
@Test
fun `present - send media failure scenario`() = runTest {
val failure = MediaPreProcessor.Failure(null)
val sendFileResult = lambdaRecorder<File, FileInfo, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _ ->
val sendFileResult = lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
Result.failure(failure)
}
val room = FakeMatrixRoom(

View file

@ -18,6 +18,9 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.Composer
import im.vector.app.features.analytics.plan.Interaction
import io.element.android.features.messages.impl.FakeMessagesNavigator
import io.element.android.features.messages.impl.MessagesNavigator
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.draft.ComposerDraftService
import io.element.android.features.messages.impl.draft.FakeComposerDraftService
import io.element.android.features.messages.impl.messagecomposer.suggestions.SuggestionsProcessor
@ -30,9 +33,7 @@ import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
@ -49,6 +50,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.matrix.test.ANOTHER_MESSAGE
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_CAPTION
import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.A_REPLY
import io.element.android.libraries.matrix.test.A_ROOM_ID
@ -58,7 +60,6 @@ import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID_3
import io.element.android.libraries.matrix.test.A_USER_ID_4
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
@ -90,8 +91,10 @@ import io.element.android.tests.testutils.lambda.any
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.waitForPredicate
import io.mockk.mockk
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -134,7 +137,6 @@ class MessageComposerPresenterTest {
assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal)
assertThat(initialState.showAttachmentSourcePicker).isFalse()
assertThat(initialState.canShareLocation).isTrue()
assertThat(initialState.attachmentsState).isEqualTo(AttachmentsState.None)
}
}
@ -211,6 +213,91 @@ class MessageComposerPresenterTest {
}
}
@Test
fun `present - change mode to edit caption`() = runTest {
val loadDraftLambda = lambdaRecorder { _: RoomId, _: Boolean ->
ComposerDraft(A_MESSAGE, A_MESSAGE, ComposerDraftType.NewMessage)
}
val updateDraftLambda = lambdaRecorder { _: RoomId, _: ComposerDraft?, _: Boolean -> }
val draftService = FakeComposerDraftService().apply {
this.loadDraftLambda = loadDraftLambda
this.saveDraftLambda = updateDraftLambda
}
val presenter = createPresenter(
coroutineScope = this,
draftService = draftService,
)
moleculeFlow(RecompositionMode.Immediate) {
val state = presenter.present()
remember(state, state.textEditorState.messageHtml()) { state }
}.test {
var state = awaitFirstItem()
val mode = anEditCaptionMode(caption = A_CAPTION)
state.eventSink.invoke(MessageComposerEvents.SetMode(mode))
state = awaitItem()
assertThat(state.mode).isEqualTo(mode)
assertThat(state.textEditorState.messageHtml()).isEqualTo(A_CAPTION)
state = backToNormalMode(state)
// The caption that was being edited is cleared and volatile draft is loaded
assertThat(state.textEditorState.messageHtml()).isEqualTo(A_MESSAGE)
assert(loadDraftLambda)
.isCalledExactly(2)
.withSequence(
// Automatic load of draft
listOf(value(A_ROOM_ID), value(false)),
// Load of volatile draft when closing edit mode
listOf(value(A_ROOM_ID), value(true))
)
assert(updateDraftLambda)
.isCalledOnce()
.with(value(A_ROOM_ID), any(), value(true))
}
}
@Test
fun `present - change mode to edit caption and send the caption`() = runTest {
val editCaptionLambda = lambdaRecorder { _: EventOrTransactionId, _: String?, _: String? ->
Result.success(Unit)
}
val timeline = FakeTimeline().apply {
this.editCaptionLambda = editCaptionLambda
}
val fakeMatrixRoom = FakeMatrixRoom(
liveTimeline = timeline,
typingNoticeResult = { Result.success(Unit) }
)
val presenter = createPresenter(
coroutineScope = this,
room = fakeMatrixRoom,
isRichTextEditorEnabled = false,
)
val permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = { Result.success("") })
presenter.test {
var state = awaitFirstItem()
val mode = anEditCaptionMode(caption = A_CAPTION)
state.eventSink.invoke(MessageComposerEvents.SetMode(mode))
state = awaitItem()
assertThat(state.mode).isEqualTo(mode)
assertThat(state.textEditorState.messageMarkdown(permalinkBuilder)).isEqualTo(A_CAPTION)
state.eventSink.invoke(MessageComposerEvents.SendMessage)
val messageSentState = awaitItem()
assertThat(messageSentState.textEditorState.messageMarkdown(permalinkBuilder)).isEqualTo("")
waitForPredicate { analyticsService.capturedEvents.size == 1 }
assertThat(analyticsService.capturedEvents).containsExactly(
Composer(
inThread = false,
isEditing = true,
isReply = false,
messageType = Composer.MessageType.Text,
)
)
assert(editCaptionLambda)
.isCalledOnce()
.with(value(AN_EVENT_ID.toEventOrTransactionId()), value(A_CAPTION), value(null))
}
}
@Test
fun `present - change mode to reply after edit`() = runTest {
val loadDraftLambda = lambdaRecorder { _: RoomId, _: Boolean ->
@ -601,7 +688,15 @@ class MessageComposerPresenterTest {
val room = FakeMatrixRoom(
typingNoticeResult = { Result.success(Unit) }
)
val presenter = createPresenter(this, room = room)
val onPreviewAttachmentLambda = lambdaRecorder { _: ImmutableList<Attachment> -> }
val navigator = FakeMessagesNavigator(
onPreviewAttachmentLambda = onPreviewAttachmentLambda
)
val presenter = createPresenter(
coroutineScope = this,
room = room,
navigator = navigator,
)
pickerProvider.givenMimeType(MimeTypes.Images)
mediaPreProcessor.givenResult(
Result.success(
@ -625,9 +720,7 @@ class MessageComposerPresenterTest {
}.test {
val initialState = awaitFirstItem()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery)
val previewingState = awaitItem()
assertThat(previewingState.showAttachmentSourcePicker).isFalse()
assertThat(previewingState.attachmentsState).isInstanceOf(AttachmentsState.Previewing::class.java)
onPreviewAttachmentLambda.assertions().isCalledOnce()
}
}
@ -636,7 +729,15 @@ class MessageComposerPresenterTest {
val room = FakeMatrixRoom(
typingNoticeResult = { Result.success(Unit) }
)
val presenter = createPresenter(this, room = room)
val onPreviewAttachmentLambda = lambdaRecorder { _: ImmutableList<Attachment> -> }
val navigator = FakeMessagesNavigator(
onPreviewAttachmentLambda = onPreviewAttachmentLambda
)
val presenter = createPresenter(
coroutineScope = this,
room = room,
navigator = navigator,
)
pickerProvider.givenMimeType(MimeTypes.Videos)
mediaPreProcessor.givenResult(
Result.success(
@ -661,9 +762,7 @@ class MessageComposerPresenterTest {
}.test {
val initialState = awaitFirstItem()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery)
val previewingState = awaitItem()
assertThat(previewingState.showAttachmentSourcePicker).isFalse()
assertThat(previewingState.attachmentsState).isInstanceOf(AttachmentsState.Previewing::class.java)
onPreviewAttachmentLambda.assertions().isCalledOnce()
}
}
@ -684,34 +783,25 @@ class MessageComposerPresenterTest {
}
@Test
fun `present - Pick file from storage`() = runTest {
val sendFileResult = lambdaRecorder<File, FileInfo, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
fun `present - Pick file from storage will open the preview`() = runTest {
val room = FakeMatrixRoom(
progressCallbackValues = listOf(
Pair(0, 10),
Pair(5, 10),
Pair(10, 10)
),
sendFileResult = sendFileResult,
typingNoticeResult = { Result.success(Unit) }
)
val presenter = createPresenter(this, room = room)
val onPreviewAttachmentLambda = lambdaRecorder { _: ImmutableList<Attachment> -> }
val navigator = FakeMessagesNavigator(
onPreviewAttachmentLambda = onPreviewAttachmentLambda
)
val presenter = createPresenter(
coroutineScope = this,
room = room,
navigator = navigator,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitFirstItem()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles)
val sendingState = awaitItem()
assertThat(sendingState.showAttachmentSourcePicker).isFalse()
assertThat(sendingState.attachmentsState).isInstanceOf(AttachmentsState.Sending.Processing::class.java)
assertThat(awaitItem().attachmentsState).isEqualTo(AttachmentsState.Sending.Uploading(0f))
assertThat(awaitItem().attachmentsState).isEqualTo(AttachmentsState.Sending.Uploading(0.5f))
assertThat(awaitItem().attachmentsState).isEqualTo(AttachmentsState.Sending.Uploading(1f))
val sentState = awaitItem()
assertThat(sentState.attachmentsState).isEqualTo(AttachmentsState.None)
sendFileResult.assertions().isCalledOnce()
onPreviewAttachmentLambda.assertions().isCalledOnce()
}
}
@ -759,19 +849,22 @@ class MessageComposerPresenterTest {
typingNoticeResult = { Result.success(Unit) }
)
val permissionPresenter = FakePermissionsPresenter().apply { setPermissionGranted() }
val onPreviewAttachmentLambda = lambdaRecorder { _: ImmutableList<Attachment> -> }
val navigator = FakeMessagesNavigator(
onPreviewAttachmentLambda = onPreviewAttachmentLambda
)
val presenter = createPresenter(
this,
coroutineScope = this,
room = room,
permissionPresenter = permissionPresenter,
navigator = navigator,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitFirstItem()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.PhotoFromCamera)
val finalState = awaitItem()
assertThat(finalState.attachmentsState).isInstanceOf(AttachmentsState.Previewing::class.java)
cancelAndIgnoreRemainingEvents()
onPreviewAttachmentLambda.assertions().isCalledOnce()
}
}
@ -781,23 +874,23 @@ class MessageComposerPresenterTest {
typingNoticeResult = { Result.success(Unit) }
)
val permissionPresenter = FakePermissionsPresenter()
val onPreviewAttachmentLambda = lambdaRecorder { _: ImmutableList<Attachment> -> }
val navigator = FakeMessagesNavigator(
onPreviewAttachmentLambda = onPreviewAttachmentLambda
)
val presenter = createPresenter(
this,
coroutineScope = this,
room = room,
permissionPresenter = permissionPresenter,
navigator = navigator,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitFirstItem()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.PhotoFromCamera)
val permissionState = awaitItem()
assertThat(permissionState.showAttachmentSourcePicker).isFalse()
assertThat(permissionState.attachmentsState).isInstanceOf(AttachmentsState.None::class.java)
permissionPresenter.setPermissionGranted()
skipItems(1)
val finalState = awaitItem()
assertThat(finalState.attachmentsState).isInstanceOf(AttachmentsState.Previewing::class.java)
onPreviewAttachmentLambda.assertions().isCalledOnce()
cancelAndIgnoreRemainingEvents()
}
}
@ -808,19 +901,22 @@ class MessageComposerPresenterTest {
typingNoticeResult = { Result.success(Unit) }
)
val permissionPresenter = FakePermissionsPresenter().apply { setPermissionGranted() }
val onPreviewAttachmentLambda = lambdaRecorder { _: ImmutableList<Attachment> -> }
val navigator = FakeMessagesNavigator(
onPreviewAttachmentLambda = onPreviewAttachmentLambda
)
val presenter = createPresenter(
this,
coroutineScope = this,
room = room,
permissionPresenter = permissionPresenter,
navigator = navigator,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitFirstItem()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.VideoFromCamera)
val finalState = awaitItem()
assertThat(finalState.attachmentsState).isInstanceOf(AttachmentsState.Previewing::class.java)
cancelAndIgnoreRemainingEvents()
onPreviewAttachmentLambda.assertions().isCalledOnce()
}
}
@ -830,10 +926,15 @@ class MessageComposerPresenterTest {
typingNoticeResult = { Result.success(Unit) }
)
val permissionPresenter = FakePermissionsPresenter()
val onPreviewAttachmentLambda = lambdaRecorder { _: ImmutableList<Attachment> -> }
val navigator = FakeMessagesNavigator(
onPreviewAttachmentLambda = onPreviewAttachmentLambda
)
val presenter = createPresenter(
this,
coroutineScope = this,
room = room,
permissionPresenter = permissionPresenter,
navigator = navigator,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@ -842,54 +943,9 @@ class MessageComposerPresenterTest {
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.VideoFromCamera)
val permissionState = awaitItem()
assertThat(permissionState.showAttachmentSourcePicker).isFalse()
assertThat(permissionState.attachmentsState).isInstanceOf(AttachmentsState.None::class.java)
permissionPresenter.setPermissionGranted()
skipItems(1)
val finalState = awaitItem()
assertThat(finalState.attachmentsState).isInstanceOf(AttachmentsState.Previewing::class.java)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - Uploading media failure can be recovered from`() = runTest {
val sendFileResult = lambdaRecorder<File, FileInfo, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _ ->
Result.failure(Exception())
}
val room = FakeMatrixRoom(
sendFileResult = sendFileResult,
typingNoticeResult = { Result.success(Unit) }
)
val presenter = createPresenter(this, room = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitFirstItem()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles)
val sendingState = awaitItem()
assertThat(sendingState.attachmentsState).isInstanceOf(AttachmentsState.Sending::class.java)
val finalState = awaitItem()
assertThat(finalState.attachmentsState).isInstanceOf(AttachmentsState.None::class.java)
snackbarDispatcher.snackbarMessage.test {
// Assert error message received
assertThat(awaitItem()).isNotNull()
}
}
}
@Test
fun `present - CancelSendAttachment stops media upload`() = runTest {
val presenter = createPresenter(this)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitFirstItem()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles)
val sendingState = awaitItem()
assertThat(sendingState.showAttachmentSourcePicker).isFalse()
assertThat(sendingState.attachmentsState).isInstanceOf(AttachmentsState.Sending.Processing::class.java)
sendingState.eventSink(MessageComposerEvents.CancelSendAttachment)
assertThat(awaitItem().attachmentsState).isEqualTo(AttachmentsState.None)
onPreviewAttachmentLambda.assertions().isCalledOnce()
}
}
@ -1473,6 +1529,7 @@ class MessageComposerPresenterTest {
room: MatrixRoom = FakeMatrixRoom(
typingNoticeResult = { Result.success(Unit) }
),
navigator: MessagesNavigator = FakeMessagesNavigator(),
pickerProvider: PickerProvider = this.pickerProvider,
featureFlagService: FeatureFlagService = this.featureFlagService,
sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(),
@ -1487,17 +1544,18 @@ class MessageComposerPresenterTest {
isRichTextEditorEnabled: Boolean = true,
draftService: ComposerDraftService = FakeComposerDraftService(),
) = MessageComposerPresenter(
coroutineScope,
room,
pickerProvider,
featureFlagService,
sessionPreferencesStore,
localMediaFactory,
MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore()),
snackbarDispatcher,
analyticsService,
DefaultMessageComposerContext(),
TestRichTextEditorStateFactory(),
navigator = navigator,
appCoroutineScope = coroutineScope,
room = room,
mediaPickerProvider = pickerProvider,
featureFlagService = featureFlagService,
sessionPreferencesStore = sessionPreferencesStore,
localMediaFactory = localMediaFactory,
mediaSender = MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore()),
snackbarDispatcher = snackbarDispatcher,
analyticsService = analyticsService,
messageComposerContext = DefaultMessageComposerContext(),
richTextEditorStateFactory = TestRichTextEditorStateFactory(),
roomAliasSuggestionsDataSource = FakeRoomAliasSuggestionsDataSource(),
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter),
permalinkParser = permalinkParser,
@ -1525,6 +1583,11 @@ fun anEditMode(
message: String = A_MESSAGE,
) = MessageComposerMode.Edit(eventOrTransactionId, message)
fun anEditCaptionMode(
eventOrTransactionId: EventOrTransactionId = AN_EVENT_ID.toEventOrTransactionId(),
caption: String = A_CAPTION,
) = MessageComposerMode.EditCaption(eventOrTransactionId, caption)
fun aReplyMode() = MessageComposerMode.Reply(
replyToDetails = InReplyToDetails.Loading(AN_EVENT_ID),
hideImage = false,

View file

@ -431,7 +431,10 @@ import kotlin.time.Duration.Companion.seconds
@Test
fun `present - PollEditClicked event navigates`() = runTest {
val navigator = FakeMessagesNavigator()
val onEditPollClickLambda = lambdaRecorder { _: EventId -> }
val navigator = FakeMessagesNavigator(
onEditPollClickLambda = onEditPollClickLambda
)
val presenter = createTimelinePresenter(
messagesNavigator = navigator,
)
@ -439,7 +442,7 @@ import kotlin.time.Duration.Companion.seconds
presenter.present()
}.test {
awaitFirstItem().eventSink(TimelineEvents.EditPoll(AN_EVENT_ID))
assertThat(navigator.onEditPollClickedCount).isEqualTo(1)
onEditPollClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID))
}
}
@ -657,35 +660,35 @@ import kotlin.time.Duration.Companion.seconds
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
return awaitItem()
}
}
internal fun TestScope.createTimelinePresenter(
timeline: Timeline = FakeTimeline(),
room: FakeMatrixRoom = FakeMatrixRoom(
liveTimeline = timeline,
canUserSendMessageResult = { _, _ -> Result.success(true) }
),
redactedVoiceMessageManager: RedactedVoiceMessageManager = FakeRedactedVoiceMessageManager(),
messagesNavigator: FakeMessagesNavigator = FakeMessagesNavigator(),
endPollAction: EndPollAction = FakeEndPollAction(),
sendPollResponseAction: SendPollResponseAction = FakeSendPollResponseAction(),
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(),
): TimelinePresenter {
return TimelinePresenter(
timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(),
room = room,
dispatchers = testCoroutineDispatchers(),
appScope = this,
navigator = messagesNavigator,
redactedVoiceMessageManager = redactedVoiceMessageManager,
endPollAction = endPollAction,
sendPollResponseAction = sendPollResponseAction,
sessionPreferencesStore = sessionPreferencesStore,
timelineItemIndexer = timelineItemIndexer,
timelineController = TimelineController(room),
resolveVerifiedUserSendFailurePresenter = { aResolveVerifiedUserSendFailureState() },
typingNotificationPresenter = { aTypingNotificationState() },
roomCallStatePresenter = { aStandByCallState() },
)
private fun TestScope.createTimelinePresenter(
timeline: Timeline = FakeTimeline(),
room: FakeMatrixRoom = FakeMatrixRoom(
liveTimeline = timeline,
canUserSendMessageResult = { _, _ -> Result.success(true) }
),
redactedVoiceMessageManager: RedactedVoiceMessageManager = FakeRedactedVoiceMessageManager(),
messagesNavigator: FakeMessagesNavigator = FakeMessagesNavigator(),
endPollAction: EndPollAction = FakeEndPollAction(),
sendPollResponseAction: SendPollResponseAction = FakeSendPollResponseAction(),
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(),
): TimelinePresenter {
return TimelinePresenter(
timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(),
room = room,
dispatchers = testCoroutineDispatchers(),
appScope = this,
navigator = messagesNavigator,
redactedVoiceMessageManager = redactedVoiceMessageManager,
endPollAction = endPollAction,
sendPollResponseAction = sendPollResponseAction,
sessionPreferencesStore = sessionPreferencesStore,
timelineItemIndexer = timelineItemIndexer,
timelineController = TimelineController(room),
resolveVerifiedUserSendFailurePresenter = { aResolveVerifiedUserSendFailureState() },
typingNotificationPresenter = { aTypingNotificationState() },
roomCallStatePresenter = { aStandByCallState() },
)
}
}

View file

@ -237,6 +237,7 @@ class TimelineItemContentMessageFactoryTest {
filename = "filename",
caption = null,
formattedCaption = null,
isEdited = false,
duration = Duration.ZERO,
videoSource = MediaSource(url = "url", json = null),
thumbnailSource = null,
@ -278,7 +279,8 @@ class TimelineItemContentMessageFactoryTest {
thumbnailSource = MediaSource("url_thumbnail"),
blurhash = A_BLUR_HASH,
),
)
),
isEdited = true,
),
senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
@ -287,6 +289,7 @@ class TimelineItemContentMessageFactoryTest {
filename = "body.mp4",
caption = "body.mp4 caption",
formattedCaption = SpannedString("formatted"),
isEdited = true,
duration = 1.minutes,
videoSource = MediaSource(url = "url", json = null),
thumbnailSource = MediaSource("url_thumbnail"),
@ -315,6 +318,7 @@ class TimelineItemContentMessageFactoryTest {
filename = "filename",
caption = null,
formattedCaption = null,
isEdited = false,
duration = Duration.ZERO,
mediaSource = MediaSource(url = "url", json = null),
mimeType = MimeTypes.OctetStream,
@ -339,7 +343,8 @@ class TimelineItemContentMessageFactoryTest {
size = 123L,
mimetype = MimeTypes.Mp3,
)
)
),
isEdited = true,
),
senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
@ -348,6 +353,7 @@ class TimelineItemContentMessageFactoryTest {
filename = "body.mp3",
caption = null,
formattedCaption = null,
isEdited = true,
duration = 1.minutes,
mediaSource = MediaSource(url = "url", json = null),
mimeType = MimeTypes.Mp3,
@ -370,6 +376,7 @@ class TimelineItemContentMessageFactoryTest {
eventId = AN_EVENT_ID,
caption = null,
formattedCaption = null,
isEdited = false,
duration = Duration.ZERO,
mediaSource = MediaSource(url = "url", json = null),
mimeType = MimeTypes.OctetStream,
@ -397,7 +404,8 @@ class TimelineItemContentMessageFactoryTest {
duration = 1.minutes,
waveform = persistentListOf(1f, 2f),
),
)
),
isEdited = true,
),
senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
@ -407,6 +415,7 @@ class TimelineItemContentMessageFactoryTest {
filename = "body.ogg",
caption = null,
formattedCaption = null,
isEdited = true,
duration = 1.minutes,
mediaSource = MediaSource(url = "url", json = null),
mimeType = MimeTypes.Ogg,
@ -433,6 +442,7 @@ class TimelineItemContentMessageFactoryTest {
filename = "filename",
caption = null,
formattedCaption = null,
isEdited = false,
duration = Duration.ZERO,
mediaSource = MediaSource(url = "url", json = null),
mimeType = MimeTypes.OctetStream,
@ -454,6 +464,7 @@ class TimelineItemContentMessageFactoryTest {
filename = "filename",
caption = "body",
formattedCaption = null,
isEdited = false,
mediaSource = MediaSource(url = "url", json = null),
thumbnailSource = null,
formattedFileSize = "0 Bytes",
@ -483,6 +494,7 @@ class TimelineItemContentMessageFactoryTest {
filename = "filename",
caption = null,
formattedCaption = null,
isEdited = false,
mediaSource = MediaSource(url = "url", json = null),
thumbnailSource = MediaSource(url = "thumbnail://url", json = null),
formattedFileSize = "8192 Bytes",
@ -520,15 +532,17 @@ class TimelineItemContentMessageFactoryTest {
thumbnailSource = MediaSource("url_thumbnail"),
blurhash = A_BLUR_HASH,
)
)
),
isEdited = true,
),
senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
val expected = TimelineItemImageContent(
filename = "body.jpg",
formattedCaption = SpannedString("formatted"),
caption = "body.jpg caption",
formattedCaption = SpannedString("formatted"),
isEdited = true,
mediaSource = MediaSource(url = "url", json = null),
thumbnailSource = MediaSource("url_thumbnail"),
formattedFileSize = "888 Bytes",
@ -556,6 +570,7 @@ class TimelineItemContentMessageFactoryTest {
filename = "filename",
caption = null,
formattedCaption = null,
isEdited = false,
fileSource = MediaSource(url = "url", json = null),
thumbnailSource = null,
formattedFileSize = "0 Bytes",
@ -586,7 +601,8 @@ class TimelineItemContentMessageFactoryTest {
),
thumbnailSource = MediaSource("url_thumbnail"),
)
)
),
isEdited = true,
),
senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
@ -595,6 +611,7 @@ class TimelineItemContentMessageFactoryTest {
filename = "body.pdf",
caption = null,
formattedCaption = null,
isEdited = true,
fileSource = MediaSource(url = "url", json = null),
thumbnailSource = MediaSource("url_thumbnail"),
formattedFileSize = "123 Bytes",

View file

@ -21,6 +21,7 @@ open class RoomListContentStateProvider : PreviewParameterProvider<RoomListConte
aRoomsContentState(summaries = persistentListOf()),
aSkeletonContentState(),
anEmptyContentState(),
anEmptyContentState(securityBannerState = SecurityBannerState.SetUpRecovery),
aRoomsContentState(securityBannerState = SecurityBannerState.NeedsNativeSlidingSyncMigration),
)
}
@ -37,4 +38,8 @@ internal fun aRoomsContentState(
internal fun aSkeletonContentState() = RoomListContentState.Skeleton(16)
internal fun anEmptyContentState() = RoomListContentState.Empty
internal fun anEmptyContentState(
securityBannerState: SecurityBannerState = SecurityBannerState.None,
) = RoomListContentState.Empty(
securityBannerState = securityBannerState,
)

View file

@ -233,11 +233,11 @@ class RoomListPresenter @Inject constructor(
val needsSlidingSyncMigration by produceState(false) {
value = client.needsSlidingSyncMigration().getOrDefault(false)
}
val securityBannerState by rememberSecurityBannerState(securityBannerDismissed, needsSlidingSyncMigration)
return when {
showEmpty -> RoomListContentState.Empty
showEmpty -> RoomListContentState.Empty(securityBannerState = securityBannerState)
showSkeleton -> RoomListContentState.Skeleton(count = 16)
else -> {
val securityBannerState by rememberSecurityBannerState(securityBannerDismissed, needsSlidingSyncMigration)
RoomListContentState.Rooms(
securityBannerState = securityBannerState,
fullScreenIntentPermissionsState = fullScreenIntentPermissionsPresenter.present(),

View file

@ -61,7 +61,9 @@ enum class SecurityBannerState {
@Immutable
sealed interface RoomListContentState {
data class Skeleton(val count: Int) : RoomListContentState
data object Empty : RoomListContentState
data class Empty(
val securityBannerState: SecurityBannerState,
) : RoomListContentState
data class Rooms(
val securityBannerState: SecurityBannerState,
val fullScreenIntentPermissionsState: FullScreenIntentPermissionsState,

View file

@ -76,6 +76,10 @@ fun RoomListContentView(
}
is RoomListContentState.Empty -> {
EmptyView(
state = contentState,
eventSink = eventSink,
onSetUpRecoveryClick = onSetUpRecoveryClick,
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
onCreateRoomClick = onCreateRoomClick,
)
}
@ -110,21 +114,44 @@ private fun SkeletonView(count: Int, modifier: Modifier = Modifier) {
@Composable
private fun EmptyView(
state: RoomListContentState.Empty,
eventSink: (RoomListEvents) -> Unit,
onSetUpRecoveryClick: () -> Unit,
onConfirmRecoveryKeyClick: () -> Unit,
onCreateRoomClick: () -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
) {
EmptyScaffold(
title = R.string.screen_roomlist_empty_title,
subtitle = R.string.screen_roomlist_empty_message,
action = {
Button(
text = stringResource(CommonStrings.action_start_chat),
leadingIcon = IconSource.Vector(CompoundIcons.Compose()),
onClick = onCreateRoomClick,
)
},
modifier = modifier.fillMaxSize(),
)
Box(modifier.fillMaxSize()) {
EmptyScaffold(
title = R.string.screen_roomlist_empty_title,
subtitle = R.string.screen_roomlist_empty_message,
action = {
Button(
text = stringResource(CommonStrings.action_start_chat),
leadingIcon = IconSource.Vector(CompoundIcons.Compose()),
onClick = onCreateRoomClick,
)
},
modifier = Modifier.align(Alignment.Center),
)
Box {
when (state.securityBannerState) {
SecurityBannerState.SetUpRecovery -> {
SetUpRecoveryKeyBanner(
onContinueClick = onSetUpRecoveryClick,
onDismissClick = { eventSink(RoomListEvents.DismissBanner) }
)
}
SecurityBannerState.RecoveryKeyConfirmation -> {
ConfirmRecoveryKeyBanner(
onContinueClick = onConfirmRecoveryKeyClick,
onDismissClick = { eventSink(RoomListEvents.DismissBanner) }
)
}
else -> Unit
}
}
}
}
@Composable

View file

@ -116,7 +116,7 @@ class SharePresenterTest {
@Test
fun `present - send media ok`() = runTest {
val sendFileResult = lambdaRecorder<File, FileInfo, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _ ->
val sendFileResult = lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
val matrixRoom = FakeMatrixRoom(

View file

@ -44,13 +44,13 @@ serialization_json = "1.7.3"
#other
coil = "2.7.0"
showkase = "1.0.3"
appyx = "1.4.0"
appyx = "1.5.1"
sqldelight = "2.0.2"
wysiwyg = "2.37.13"
telephoto = "0.14.0"
# Dependency analysis
dependencyAnalysis = "2.4.2"
dependencyAnalysis = "2.5.0"
# DI
dagger = "2.52"
@ -154,7 +154,7 @@ test_konsist = "com.lemonappdev:konsist:0.16.1"
test_turbine = "app.cash.turbine:turbine:1.2.0"
test_truth = "com.google.truth:truth:1.4.4"
test_parameter_injector = "com.google.testparameterinjector:test-parameter-injector:1.18"
test_robolectric = "org.robolectric:robolectric:4.14"
test_robolectric = "org.robolectric:robolectric:4.14.1"
test_appyx_junit = { module = "com.bumble.appyx:testing-junit4", version.ref = "appyx" }
test_composable_preview_scanner = "com.github.sergio-sastre.ComposablePreviewScanner:android:0.1.2"
@ -163,7 +163,7 @@ coil = { module = "io.coil-kt:coil", version.ref = "coil" }
coil_compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" }
coil_gif = { module = "io.coil-kt:coil-gif", version.ref = "coil" }
coil_test = { module = "io.coil-kt:coil-test", version.ref = "coil" }
compound = { module = "io.element.android:compound-android", version = "0.1.1" }
compound = { module = "io.element.android:compound-android", version = "0.2.0" }
datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" }
serialization_json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization_json" }
kotlinx_collections_immutable = "org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.8"
@ -173,7 +173,7 @@ jsoup = "org.jsoup:jsoup:1.18.1"
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
molecule-runtime = "app.cash.molecule:molecule-runtime:2.0.0"
timber = "com.jakewharton.timber:timber:5.0.1"
matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.2.62"
matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.2.64"
matrix_richtexteditor = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" }
matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", version.ref = "wysiwyg" }
sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
@ -195,7 +195,7 @@ zxing_cpp = "io.github.zxing-cpp:android:2.2.0"
# Analytics
posthog = "com.posthog:posthog-android:3.9.2"
sentry = "io.sentry:sentry-android:7.17.0"
sentry = "io.sentry:sentry-android:7.18.0"
# main branch can be tested replacing the version with main-SNAPSHOT
matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.28.0"
@ -241,6 +241,6 @@ paparazzi = "app.cash.paparazzi:1.3.5"
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
firebaseAppDistribution = { id = "com.google.firebase.appdistribution", version.ref = "firebaseAppDistribution" }
knit = { id = "org.jetbrains.kotlinx.knit", version = "0.5.0" }
sonarqube = "org.sonarqube:5.1.0.4882"
sonarqube = "org.sonarqube:6.0.0.5145"
licensee = "app.cash.licensee:1.12.0"
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }

View file

@ -147,9 +147,21 @@ interface MatrixRoom : Closeable {
progressCallback: ProgressCallback?
): Result<MediaUploadHandler>
suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler>
suspend fun sendAudio(
file: File,
audioInfo: AudioInfo,
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
): Result<MediaUploadHandler>
suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler>
suspend fun sendFile(
file: File,
fileInfo: FileInfo,
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
): Result<MediaUploadHandler>
suspend fun toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId): Result<Unit>

View file

@ -59,10 +59,17 @@ interface Timeline : AutoCloseable {
suspend fun editMessage(
eventOrTransactionId: EventOrTransactionId,
body: String, htmlBody: String?,
body: String,
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
): Result<Unit>
suspend fun editCaption(
eventOrTransactionId: EventOrTransactionId,
caption: String?,
formattedCaption: String?,
): Result<Unit>
suspend fun replyMessage(
eventId: EventId,
body: String,
@ -91,9 +98,21 @@ interface Timeline : AutoCloseable {
suspend fun redactEvent(eventOrTransactionId: EventOrTransactionId, reason: String?): Result<Unit>
suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler>
suspend fun sendAudio(
file: File,
audioInfo: AudioInfo,
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
): Result<MediaUploadHandler>
suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler>
suspend fun sendFile(
file: File,
fileInfo: FileInfo,
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
): Result<MediaUploadHandler>
suspend fun toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId): Result<Unit>

View file

@ -57,6 +57,7 @@ enum class Target(open val filter: String) {
MATRIX_SDK_HTTP_CLIENT("matrix_sdk::http_client"),
MATRIX_SDK_CLIENT("matrix_sdk::client"),
MATRIX_SDK_OIDC("matrix_sdk::oidc"),
MATRIX_SDK_SEND_QUEUE("matrix_sdk::send_queue"),
MATRIX_SDK_SLIDING_SYNC("matrix_sdk::sliding_sync"),
MATRIX_SDK_BASE_SLIDING_SYNC("matrix_sdk_base::sliding_sync"),
MATRIX_SDK_UI_TIMELINE("matrix_sdk_ui::timeline"),

View file

@ -180,6 +180,7 @@ class RustMatrixClient(
sessionCoroutineScope = sessionCoroutineScope,
)
private val roomMembershipObserver = RoomMembershipObserver()
private val roomFactory = RustRoomFactory(
roomListService = roomListService,
innerRoomListService = innerRoomListService,
@ -193,6 +194,7 @@ class RustMatrixClient(
roomSyncSubscriber = roomSyncSubscriber,
timelineEventTypeFilterFactory = timelineEventTypeFilterFactory,
featureFlagService = featureFlagService,
roomMembershipObserver = roomMembershipObserver,
)
override val mediaLoader: MatrixMediaLoader = RustMediaLoader(
@ -201,8 +203,6 @@ class RustMatrixClient(
innerClient = innerClient,
)
private val roomMembershipObserver = RoomMembershipObserver()
private var clientDelegateTaskHandle: TaskHandle? = innerClient.setDelegate(sessionDelegate)
private val _userProfile: MutableStateFlow<MatrixUser> = MutableStateFlow(

View file

@ -35,6 +35,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.MatrixRoomNotificationSettingsState
import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
import io.element.android.libraries.matrix.api.room.location.AssetType
@ -60,7 +61,6 @@ import io.element.android.libraries.matrix.impl.widget.RustWidgetDriver
import io.element.android.libraries.matrix.impl.widget.generateWidgetWebViewUrl
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
@ -92,7 +92,6 @@ import org.matrix.rustcomponents.sdk.IdentityStatusChange as RustIdentityStateCh
import org.matrix.rustcomponents.sdk.Room as InnerRoom
import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline
@OptIn(ExperimentalCoroutinesApi::class)
class RustMatrixRoom(
override val sessionId: SessionId,
private val deviceId: DeviceId,
@ -107,6 +106,7 @@ class RustMatrixRoom(
private val roomSyncSubscriber: RoomSyncSubscriber,
private val matrixRoomInfoMapper: MatrixRoomInfoMapper,
private val featureFlagService: FeatureFlagService,
private val roomMembershipObserver: RoomMembershipObserver,
) : MatrixRoom {
override val roomId = RoomId(innerRoom.id())
@ -376,6 +376,8 @@ class RustMatrixRoom(
override suspend fun leave(): Result<Unit> = withContext(roomDispatcher) {
runCatching {
innerRoom.leave()
}.onSuccess {
roomMembershipObserver.notifyUserLeftRoom(roomId)
}
}
@ -467,12 +469,36 @@ class RustMatrixRoom(
return liveTimeline.sendVideo(file, thumbnailFile, videoInfo, caption, formattedCaption, progressCallback)
}
override suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler> {
return liveTimeline.sendAudio(file, audioInfo, progressCallback)
override suspend fun sendAudio(
file: File,
audioInfo: AudioInfo,
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
): Result<MediaUploadHandler> {
return liveTimeline.sendAudio(
file = file,
audioInfo = audioInfo,
caption = caption,
formattedCaption = formattedCaption,
progressCallback = progressCallback,
)
}
override suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler> {
return liveTimeline.sendFile(file, fileInfo, progressCallback)
override suspend fun sendFile(
file: File,
fileInfo: FileInfo,
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
): Result<MediaUploadHandler> {
return liveTimeline.sendFile(
file,
fileInfo,
caption,
formattedCaption,
progressCallback,
)
}
override suspend fun toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId): Result<Unit> {

View file

@ -10,15 +10,19 @@ package io.element.android.libraries.matrix.impl.room
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.room.PendingRoom
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import org.matrix.rustcomponents.sdk.RoomPreview
class RustPendingRoom(
override val sessionId: SessionId,
override val roomId: RoomId,
private val inner: RoomPreview,
private val roomMembershipObserver: RoomMembershipObserver,
) : PendingRoom {
override suspend fun leave(): Result<Unit> = runCatching {
inner.leave()
}.onSuccess {
roomMembershipObserver.notifyUserLeftRoom(roomId)
}
override fun close() {

View file

@ -17,13 +17,13 @@ import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.PendingRoom
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.awaitLoaded
import io.element.android.libraries.matrix.impl.roomlist.fullRoomWithTimeline
import io.element.android.libraries.matrix.impl.roomlist.roomOrNull
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@ -51,8 +51,8 @@ class RustRoomFactory(
private val roomSyncSubscriber: RoomSyncSubscriber,
private val timelineEventTypeFilterFactory: TimelineEventTypeFilterFactory,
private val featureFlagService: FeatureFlagService,
private val roomMembershipObserver: RoomMembershipObserver,
) {
@OptIn(ExperimentalCoroutinesApi::class)
private val dispatcher = dispatchers.io.limitedParallelism(1)
private val mutex = Mutex()
private var isDestroyed: Boolean = false
@ -120,6 +120,7 @@ class RustRoomFactory(
roomSyncSubscriber = roomSyncSubscriber,
matrixRoomInfoMapper = matrixRoomInfoMapper,
featureFlagService = featureFlagService,
roomMembershipObserver = roomMembershipObserver,
)
}
}
@ -148,6 +149,7 @@ class RustRoomFactory(
sessionId = sessionId,
roomId = roomId,
inner = innerRoom,
roomMembershipObserver = roomMembershipObserver,
)
}

View file

@ -295,22 +295,40 @@ class RustTimeline(
body: String,
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
): Result<Unit> =
withContext(dispatcher) {
runCatching<Unit> {
val editedContent = EditedContent.RoomMessage(
content = MessageEventContent.from(
body = body,
htmlBody = htmlBody,
intentionalMentions = intentionalMentions
),
)
inner.edit(
newContent = editedContent,
eventOrTransactionId = eventOrTransactionId.toRustEventOrTransactionId(),
)
}
): Result<Unit> = withContext(dispatcher) {
runCatching<Unit> {
val editedContent = EditedContent.RoomMessage(
content = MessageEventContent.from(
body = body,
htmlBody = htmlBody,
intentionalMentions = intentionalMentions
),
)
inner.edit(
newContent = editedContent,
eventOrTransactionId = eventOrTransactionId.toRustEventOrTransactionId(),
)
}
}
override suspend fun editCaption(
eventOrTransactionId: EventOrTransactionId,
caption: String?,
formattedCaption: String?,
): Result<Unit> = withContext(dispatcher) {
runCatching<Unit> {
val editedContent = EditedContent.MediaCaption(
caption = caption,
formattedCaption = formattedCaption?.let {
FormattedBody(body = it, format = MessageFormat.Html)
},
)
inner.edit(
newContent = editedContent,
eventOrTransactionId = eventOrTransactionId.toRustEventOrTransactionId(),
)
}
}
override suspend fun replyMessage(
eventId: EventId,
@ -373,27 +391,44 @@ class RustTimeline(
}
}
override suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler> {
override suspend fun sendAudio(
file: File,
audioInfo: AudioInfo,
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
): Result<MediaUploadHandler> {
val useSendQueue = featureFlagsService.isFeatureEnabled(FeatureFlags.MediaUploadOnSendQueue)
return sendAttachment(listOf(file)) {
inner.sendAudio(
url = file.path,
audioInfo = audioInfo.map(),
// Maybe allow a caption in the future?
caption = null,
formattedCaption = null,
caption = caption,
formattedCaption = formattedCaption?.let {
FormattedBody(body = it, format = MessageFormat.Html)
},
useSendQueue = useSendQueue,
progressWatcher = progressCallback?.toProgressWatcher()
)
}
}
override suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler> {
override suspend fun sendFile(
file: File,
fileInfo: FileInfo,
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
): Result<MediaUploadHandler> {
val useSendQueue = featureFlagsService.isFeatureEnabled(FeatureFlags.MediaUploadOnSendQueue)
return sendAttachment(listOf(file)) {
inner.sendFile(
url = file.path,
fileInfo = fileInfo.map(),
caption = caption,
formattedCaption = formattedCaption?.let {
FormattedBody(body = it, format = MessageFormat.Html)
},
useSendQueue = useSendQueue,
progressWatcher = progressCallback?.toProgressWatcher(),
)

View file

@ -96,6 +96,17 @@ class RustSessionVerificationService(
private var listener: SessionVerificationServiceListener? = null
init {
// Instantiate the verification controller when possible, this is needed to get incoming verification requests
sessionCoroutineScope.launch {
// Needed to avoid crashes on unit tests due to the Rust SDK not being available
tryOrNull {
encryptionService.waitForE2eeInitializationTasks()
initVerificationControllerIfNeeded()
}
}
}
override fun setListener(listener: SessionVerificationServiceListener?) {
this.listener = listener
}

View file

@ -32,5 +32,6 @@ internal fun aRustRoomPreviewInfo(
isHistoryWorldReadable = true,
membership = membership,
joinRule = joinRule,
heroes = null,
)
}

View file

@ -92,10 +92,10 @@ class FakeMatrixRoom(
{ _, _, _, _, _, _ -> lambdaError() },
private val sendVideoResult: (File, File?, VideoInfo, String?, String?, ProgressCallback?) -> Result<FakeMediaUploadHandler> =
{ _, _, _, _, _, _ -> lambdaError() },
private val sendFileResult: (File, FileInfo, ProgressCallback?) -> Result<FakeMediaUploadHandler> =
{ _, _, _ -> lambdaError() },
private val sendAudioResult: (File, AudioInfo, ProgressCallback?) -> Result<FakeMediaUploadHandler> =
{ _, _, _ -> lambdaError() },
private val sendFileResult: (File, FileInfo, String?, String?, ProgressCallback?) -> Result<FakeMediaUploadHandler> =
{ _, _, _, _, _ -> lambdaError() },
private val sendAudioResult: (File, AudioInfo, String?, String?, ProgressCallback?) -> Result<FakeMediaUploadHandler> =
{ _, _, _, _, _ -> lambdaError() },
private val sendVoiceMessageResult: (File, AudioInfo, List<Float>, ProgressCallback?) -> Result<FakeMediaUploadHandler> =
{ _, _, _, _ -> lambdaError() },
private val setNameResult: (String) -> Result<Unit> = { lambdaError() },
@ -354,12 +354,16 @@ class FakeMatrixRoom(
override suspend fun sendAudio(
file: File,
audioInfo: AudioInfo,
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?
): Result<MediaUploadHandler> = simulateLongTask {
simulateSendMediaProgress(progressCallback)
sendAudioResult(
file,
audioInfo,
caption,
formattedCaption,
progressCallback,
)
}
@ -367,12 +371,16 @@ class FakeMatrixRoom(
override suspend fun sendFile(
file: File,
fileInfo: FileInfo,
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?
): Result<MediaUploadHandler> = simulateLongTask {
simulateSendMediaProgress(progressCallback)
sendFileResult(
file,
fileInfo,
caption,
formattedCaption,
progressCallback,
)
}

View file

@ -92,6 +92,24 @@ class FakeTimeline(
intentionalMentions
)
var editCaptionLambda: (
eventOrTransactionId: EventOrTransactionId,
caption: String?,
formattedCaption: String?,
) -> Result<Unit> = { _, _, _ ->
lambdaError()
}
override suspend fun editCaption(
eventOrTransactionId: EventOrTransactionId,
caption: String?,
formattedCaption: String?,
): Result<Unit> = editCaptionLambda(
eventOrTransactionId,
caption,
formattedCaption,
)
var replyMessageLambda: (
eventId: EventId,
body: String,
@ -173,36 +191,48 @@ class FakeTimeline(
var sendAudioLambda: (
file: File,
audioInfo: AudioInfo,
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
) -> Result<MediaUploadHandler> = { _, _, _ ->
) -> Result<MediaUploadHandler> = { _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
override suspend fun sendAudio(
file: File,
audioInfo: AudioInfo,
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
): Result<MediaUploadHandler> = sendAudioLambda(
file,
audioInfo,
caption,
formattedCaption,
progressCallback
)
var sendFileLambda: (
file: File,
fileInfo: FileInfo,
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
) -> Result<MediaUploadHandler> = { _, _, _ ->
) -> Result<MediaUploadHandler> = { _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
override suspend fun sendFile(
file: File,
fileInfo: FileInfo,
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
): Result<MediaUploadHandler> = sendFileLambda(
file,
fileInfo,
caption,
formattedCaption,
progressCallback
)

View file

@ -13,25 +13,23 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.core.content.FileProvider
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.mediapickers.api.ComposePickerLauncher
import io.element.android.libraries.mediapickers.api.NoOpPickerLauncher
import io.element.android.libraries.mediapickers.api.PickerLauncher
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediapickers.api.PickerType
import java.io.File
import java.util.UUID
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultPickerProvider(private val isInTest: Boolean) : PickerProvider {
@Inject
constructor() : this(false)
class DefaultPickerProvider @Inject constructor(
@ApplicationContext private val context: Context,
) : PickerProvider {
/**
* Remembers and returns a [PickerLauncher] for a certain media/file [type].
*/
@ -40,7 +38,7 @@ class DefaultPickerProvider(private val isInTest: Boolean) : PickerProvider {
type: PickerType<Input, Output>,
onResult: (Output) -> Unit,
): PickerLauncher<Input, Output> {
return if (LocalInspectionMode.current || isInTest) {
return if (LocalInspectionMode.current) {
NoOpPickerLauncher { }
} else {
val contract = type.getContract()
@ -56,7 +54,7 @@ class DefaultPickerProvider(private val isInTest: Boolean) : PickerProvider {
@Composable
override fun registerGalleryImagePicker(onResult: (Uri?) -> Unit): PickerLauncher<PickVisualMediaRequest, Uri?> {
// Tests and UI preview can't handle Contexts, so we might as well disable the whole picker
return if (LocalInspectionMode.current || isInTest) {
return if (LocalInspectionMode.current) {
NoOpPickerLauncher { onResult(null) }
} else {
rememberPickerLauncher(type = PickerType.Image) { uri -> onResult(uri) }
@ -72,10 +70,9 @@ class DefaultPickerProvider(private val isInTest: Boolean) : PickerProvider {
onResult: (uri: Uri?, mimeType: String?) -> Unit
): PickerLauncher<PickVisualMediaRequest, Uri?> {
// Tests and UI preview can't handle Contexts, so we might as well disable the whole picker
return if (LocalInspectionMode.current || isInTest) {
return if (LocalInspectionMode.current) {
NoOpPickerLauncher { onResult(null, null) }
} else {
val context = LocalContext.current
rememberPickerLauncher(type = PickerType.ImageAndVideo) { uri ->
val mimeType = uri?.let { context.contentResolver.getType(it) }
onResult(uri, mimeType)
@ -93,7 +90,7 @@ class DefaultPickerProvider(private val isInTest: Boolean) : PickerProvider {
onResult: (Uri?) -> Unit,
): PickerLauncher<String, Uri?> {
// Tests and UI preview can't handle Context or FileProviders, so we might as well disable the whole picker
return if (LocalInspectionMode.current || isInTest) {
return if (LocalInspectionMode.current) {
NoOpPickerLauncher { onResult(null) }
} else {
rememberPickerLauncher(type = PickerType.File(mimeType)) { uri -> onResult(uri) }
@ -107,12 +104,11 @@ class DefaultPickerProvider(private val isInTest: Boolean) : PickerProvider {
@Composable
override fun registerCameraPhotoPicker(onResult: (Uri?) -> Unit): PickerLauncher<Uri, Boolean> {
// Tests and UI preview can't handle Context or FileProviders, so we might as well disable the whole picker
return if (LocalInspectionMode.current || isInTest) {
return if (LocalInspectionMode.current) {
NoOpPickerLauncher { onResult(null) }
} else {
val context = LocalContext.current
val tmpFile = remember { getTemporaryFile(context) }
val tmpFileUri = remember(tmpFile) { getTemporaryUri(context, tmpFile) }
val tmpFile = remember { getTemporaryFile("photo.jpg") }
val tmpFileUri = remember(tmpFile) { getTemporaryUri(tmpFile) }
rememberPickerLauncher(type = PickerType.Camera.Photo(tmpFileUri)) { success ->
// Execute callback
onResult(if (success) tmpFileUri else null)
@ -127,12 +123,11 @@ class DefaultPickerProvider(private val isInTest: Boolean) : PickerProvider {
@Composable
override fun registerCameraVideoPicker(onResult: (Uri?) -> Unit): PickerLauncher<Uri, Boolean> {
// Tests and UI preview can't handle Context or FileProviders, so we might as well disable the whole picker
return if (LocalInspectionMode.current || isInTest) {
return if (LocalInspectionMode.current) {
NoOpPickerLauncher { onResult(null) }
} else {
val context = LocalContext.current
val tmpFile = remember { getTemporaryFile(context) }
val tmpFileUri = remember(tmpFile) { getTemporaryUri(context, tmpFile) }
val tmpFile = remember { getTemporaryFile("video.mp4") }
val tmpFileUri = remember(tmpFile) { getTemporaryUri(tmpFile) }
rememberPickerLauncher(type = PickerType.Camera.Video(tmpFileUri)) { success ->
// Execute callback
onResult(if (success) tmpFileUri else null)
@ -141,15 +136,12 @@ class DefaultPickerProvider(private val isInTest: Boolean) : PickerProvider {
}
private fun getTemporaryFile(
context: Context,
baseFolder: File = context.cacheDir,
filename: String = UUID.randomUUID().toString(),
filename: String,
): File {
return File(baseFolder, filename)
return File(context.cacheDir, filename)
}
private fun getTemporaryUri(
context: Context,
file: File,
): Uri {
val authority = "${context.packageName}.fileprovider"

View file

@ -125,6 +125,8 @@ class MediaSender @Inject constructor(
sendAudio(
file = uploadInfo.file,
audioInfo = uploadInfo.audioInfo,
caption = caption,
formattedCaption = formattedCaption,
progressCallback = progressCallback
)
}
@ -140,6 +142,8 @@ class MediaSender @Inject constructor(
sendFile(
file = uploadInfo.file,
fileInfo = uploadInfo.fileInfo,
caption = caption,
formattedCaption = formattedCaption,
progressCallback = progressCallback
)
}

View file

@ -91,7 +91,7 @@ class MediaSenderTest {
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `given a cancellation in the media upload when sending the job is cancelled`() = runTest(StandardTestDispatcher()) {
val sendFileResult = lambdaRecorder<File, FileInfo, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _ ->
val sendFileResult = lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
val room = FakeMatrixRoom(

View file

@ -47,6 +47,16 @@ internal fun ComposerModeView(
when (composerMode) {
is MessageComposerMode.Edit -> {
EditingModeView(
text = stringResource(CommonStrings.common_editing),
modifier = modifier,
onResetComposerMode = onResetComposerMode,
)
}
is MessageComposerMode.EditCaption -> {
EditingModeView(
text = stringResource(
if (composerMode.content.isEmpty()) CommonStrings.common_adding_caption else CommonStrings.common_editing_caption
),
modifier = modifier,
onResetComposerMode = onResetComposerMode,
)
@ -65,6 +75,7 @@ internal fun ComposerModeView(
@Composable
private fun EditingModeView(
onResetComposerMode: () -> Unit,
text: String,
modifier: Modifier = Modifier,
) {
Row(
@ -76,14 +87,14 @@ private fun EditingModeView(
) {
Icon(
imageVector = CompoundIcons.Edit(),
contentDescription = stringResource(CommonStrings.common_editing),
contentDescription = null,
tint = ElementTheme.materialColors.secondary,
modifier = Modifier
.padding(vertical = 8.dp)
.size(16.dp),
)
Text(
stringResource(CommonStrings.common_editing),
text = text,
style = ElementTheme.typography.fontBodySmRegular,
textAlign = TextAlign.Start,
color = ElementTheme.materialColors.secondary,

View file

@ -121,19 +121,25 @@ fun TextComposer(
}
val layoutModifier = modifier
.fillMaxSize()
.height(IntrinsicSize.Min)
.fillMaxSize()
.height(IntrinsicSize.Min)
val composerOptionsButton: @Composable () -> Unit = remember {
val composerOptionsButton: @Composable () -> Unit = remember(composerMode) {
@Composable {
if (composerMode is MessageComposerMode.Attachment) {
Spacer(modifier = Modifier.width(9.dp))
} else {
ComposerOptionsButton(
modifier = Modifier
.size(48.dp),
onClick = onAddAttachment
)
when (composerMode) {
is MessageComposerMode.Attachment -> {
Spacer(modifier = Modifier.width(9.dp))
}
is MessageComposerMode.EditCaption -> {
Spacer(modifier = Modifier.width(16.dp))
}
else -> {
ComposerOptionsButton(
modifier = Modifier
.size(48.dp),
onClick = onAddAttachment
)
}
}
}
}
@ -331,8 +337,8 @@ private fun StandardLayout(
if (voiceMessageState is VoiceMessageState.Preview || voiceMessageState is VoiceMessageState.Recording) {
Box(
modifier = Modifier
.padding(bottom = 5.dp, top = 5.dp, end = 3.dp, start = 3.dp)
.size(48.dp),
.padding(bottom = 5.dp, top = 5.dp, end = 3.dp, start = 3.dp)
.size(48.dp),
contentAlignment = Alignment.Center,
) {
voiceDeleteButton()
@ -342,8 +348,8 @@ private fun StandardLayout(
}
Box(
modifier = Modifier
.padding(bottom = 8.dp, top = 8.dp)
.weight(1f)
.padding(bottom = 8.dp, top = 8.dp)
.weight(1f)
) {
voiceRecording()
}
@ -356,16 +362,16 @@ private fun StandardLayout(
}
Box(
modifier = Modifier
.padding(bottom = 8.dp, top = 8.dp)
.weight(1f)
.padding(bottom = 8.dp, top = 8.dp)
.weight(1f)
) {
textInput()
}
}
Box(
Modifier
.padding(bottom = 5.dp, top = 5.dp, end = 6.dp, start = 6.dp)
.size(48.dp),
Modifier
.padding(bottom = 5.dp, top = 5.dp, end = 6.dp, start = 6.dp)
.size(48.dp),
contentAlignment = Alignment.Center,
) {
endButton()
@ -387,8 +393,8 @@ private fun TextFormattingLayout(
) {
Box(
modifier = Modifier
.weight(1f)
.padding(horizontal = 12.dp)
.weight(1f)
.padding(horizontal = 12.dp)
) {
textInput()
}
@ -432,11 +438,11 @@ private fun TextInputBox(
Column(
modifier = Modifier
.clip(roundedCorners)
.border(0.5.dp, borderColor, roundedCorners)
.background(color = bgColor)
.requiredHeightIn(min = 42.dp)
.fillMaxSize(),
.clip(roundedCorners)
.border(0.5.dp, borderColor, roundedCorners)
.background(color = bgColor)
.requiredHeightIn(min = 42.dp)
.fillMaxSize(),
) {
if (composerMode is MessageComposerMode.Special) {
ComposerModeView(
@ -447,9 +453,9 @@ private fun TextInputBox(
val defaultTypography = ElementTheme.typography.fontBodyLgRegular
Box(
modifier = Modifier
.padding(top = 4.dp, bottom = 4.dp, start = 12.dp, end = 12.dp)
// Apply test tag only once, otherwise 2 nodes will have it (both the normal and subcomposing one) and tests will fail
.then(if (!subcomposing) Modifier.testTag(TestTags.textEditor) else Modifier),
.padding(top = 4.dp, bottom = 4.dp, start = 12.dp, end = 12.dp)
// Apply test tag only once, otherwise 2 nodes will have it (both the normal and subcomposing one) and tests will fail
.then(if (!subcomposing) Modifier.testTag(TestTags.textEditor) else Modifier),
contentAlignment = Alignment.CenterStart,
) {
// Placeholder
@ -495,8 +501,8 @@ private fun TextInput(
// This prevents it gaining focus and mutating the state.
registerStateUpdates = !subcomposing,
modifier = Modifier
.padding(top = 6.dp, bottom = 6.dp)
.fillMaxWidth(),
.padding(top = 6.dp, bottom = 6.dp)
.fillMaxWidth(),
style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus),
resolveMentionDisplay = resolveMentionDisplay,
resolveRoomMentionDisplay = resolveRoomMentionDisplay,
@ -573,6 +579,42 @@ internal fun TextComposerEditPreview() = ElementPreview {
}
}
@PreviewsDayNight
@Composable
internal fun TextComposerEditCaptionPreview() = ElementPreview {
PreviewColumn(
items = aTextEditorStateRichList()
) { _, textEditorState ->
ATextComposer(
state = textEditorState,
voiceMessageState = VoiceMessageState.Idle,
composerMode = aMessageComposerModeEditCaption(
// Set an existing caption so that the UI will be in edit caption mode
content = "An existing caption",
),
enableVoiceMessages = false,
)
}
}
@PreviewsDayNight
@Composable
internal fun TextComposerAddCaptionPreview() = ElementPreview {
PreviewColumn(
items = aTextEditorStateRichList()
) { _, textEditorState ->
ATextComposer(
state = textEditorState,
voiceMessageState = VoiceMessageState.Idle,
composerMode = aMessageComposerModeEditCaption(
// No caption so that the UI will be in add caption mode
content = "",
),
enableVoiceMessages = false,
)
}
}
@PreviewsDayNight
@Composable
internal fun MarkdownTextComposerEditPreview() = ElementPreview {
@ -717,6 +759,14 @@ fun aMessageComposerModeEdit(
content = content
)
fun aMessageComposerModeEditCaption(
eventOrTransactionId: EventOrTransactionId = EventId("$1234").toEventOrTransactionId(),
content: String,
) = MessageComposerMode.EditCaption(
eventOrTransactionId = eventOrTransactionId,
content = content
)
fun aMessageComposerModeReply(
replyToDetails: InReplyToDetails,
hideImage: Boolean = false,

View file

@ -7,7 +7,6 @@
package io.element.android.libraries.textcomposer.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
@ -17,7 +16,13 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.LinearGradientShader
import androidx.compose.ui.graphics.RadialGradientShader
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
@ -31,6 +36,10 @@ import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTran
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.ui.strings.CommonStrings
/**
* Send button for the message composer.
* Figma: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=1956-37575&node-type=frame&m=dev
*/
@Composable
internal fun SendButton(
canSendMessage: Boolean,
@ -44,23 +53,29 @@ internal fun SendButton(
onClick = onClick,
enabled = canSendMessage,
) {
val iconVector = when (composerMode) {
is MessageComposerMode.Edit -> CompoundIcons.Check()
else -> CompoundIcons.Send()
val iconVector = when {
composerMode.isEditing -> CompoundIcons.Check()
else -> CompoundIcons.SendSolid()
}
val iconStartPadding = when (composerMode) {
is MessageComposerMode.Edit -> 0.dp
val iconStartPadding = when {
composerMode.isEditing -> 0.dp
else -> 2.dp
}
val contentDescription = when (composerMode) {
is MessageComposerMode.Edit -> stringResource(CommonStrings.action_edit)
val contentDescription = when {
composerMode.isEditing -> stringResource(CommonStrings.action_edit)
else -> stringResource(CommonStrings.action_send)
}
Box(
modifier = Modifier
.clip(CircleShape)
.size(36.dp)
.background(if (canSendMessage) ElementTheme.colors.iconAccentTertiary else Color.Transparent)
.then(
if (canSendMessage) {
buttonBackgroundModifier()
} else {
Modifier
}
)
) {
Icon(
modifier = Modifier
@ -68,13 +83,55 @@ internal fun SendButton(
.align(Alignment.Center),
imageVector = iconVector,
contentDescription = contentDescription,
// Exception here, we use Color.White instead of ElementTheme.colors.iconOnSolidPrimary
tint = if (canSendMessage) Color.White else ElementTheme.colors.iconDisabled
tint = if (canSendMessage) {
if (ElementTheme.colors.isLight) {
ElementTheme.colors.iconOnSolidPrimary
} else {
ElementTheme.colors.iconPrimary
}
} else {
ElementTheme.colors.iconQuaternary
}
)
}
}
}
private fun buttonBackgroundModifier() = Modifier.drawWithCache {
// We have a square button, so height == width.
val height = size.height
val verticalGradientBrush = ShaderBrush(
LinearGradientShader(
from = Offset(0f, 0f),
to = Offset(0f, height),
colors = listOf(
Color(0xFF0BC491),
Color(0xFF0467DD),
)
)
)
val radialGradientBrush = ShaderBrush(
RadialGradientShader(
center = Offset(height / 2f, height / 2f),
radius = height / 2f,
colors = listOf(
Color(0xFF0BC491),
Color(0xFF0467DD),
)
)
)
onDrawBehind {
drawRect(
brush = verticalGradientBrush,
)
drawRect(
brush = radialGradientBrush,
alpha = 0.4f,
blendMode = BlendMode.Overlay,
)
}
}
@PreviewsDayNight
@Composable
internal fun SendButtonPreview() = ElementPreview {

View file

@ -27,6 +27,11 @@ sealed interface MessageComposerMode {
val content: String
) : Special
data class EditCaption(
val eventOrTransactionId: EventOrTransactionId,
val content: String
) : Special
data class Reply(
val replyToDetails: InReplyToDetails,
val hideImage: Boolean,
@ -34,16 +39,8 @@ sealed interface MessageComposerMode {
val eventId: EventId = replyToDetails.eventId()
}
val relatedEventId: EventId?
get() = when (this) {
is Normal,
is Attachment -> null
is Edit -> eventOrTransactionId.eventId
is Reply -> eventId
}
val isEditing: Boolean
get() = this is Edit
get() = this is Edit || this is EditCaption
val isReply: Boolean
get() = this is Reply

View file

@ -32,6 +32,7 @@
<string name="a11y_voice_message_record">"Record voice message."</string>
<string name="a11y_voice_message_stop_recording">"Stop recording"</string>
<string name="action_accept">"Accept"</string>
<string name="action_add_caption">"Add caption"</string>
<string name="action_add_to_timeline">"Add to timeline"</string>
<string name="action_back">"Back"</string>
<string name="action_call">"Call"</string>
@ -57,6 +58,7 @@
<string name="action_discard">"Discard"</string>
<string name="action_done">"Done"</string>
<string name="action_edit">"Edit"</string>
<string name="action_edit_caption">"Edit caption"</string>
<string name="action_edit_poll">"Edit poll"</string>
<string name="action_enable">"Enable"</string>
<string name="action_end_poll">"End poll"</string>
@ -91,6 +93,7 @@
<string name="action_react">"React"</string>
<string name="action_reject">"Reject"</string>
<string name="action_remove">"Remove"</string>
<string name="action_remove_caption">"Remove caption"</string>
<string name="action_reply">"Reply"</string>
<string name="action_reply_in_thread">"Reply in thread"</string>
<string name="action_report_bug">"Report bug"</string>
@ -123,6 +126,7 @@
<string name="action_yes">"Yes"</string>
<string name="common_about">"About"</string>
<string name="common_acceptable_use_policy">"Acceptable use policy"</string>
<string name="common_adding_caption">"Adding caption"</string>
<string name="common_advanced_settings">"Advanced settings"</string>
<string name="common_analytics">"Analytics"</string>
<string name="common_appearance">"Appearance"</string>
@ -143,6 +147,7 @@
<string name="common_do_not_show_this_again">"Do not show this again"</string>
<string name="common_edited_suffix">"(edited)"</string>
<string name="common_editing">"Editing"</string>
<string name="common_editing_caption">"Editing caption"</string>
<string name="common_emote">"* %1$s %2$s"</string>
<string name="common_encryption">"Encryption"</string>
<string name="common_encryption_enabled">"Encryption enabled"</string>

View file

@ -47,7 +47,7 @@ private const val versionMinor = 7
// Note: even values are reserved for regular release, odd values for hotfix release.
// When creating a hotfix, you should decrease the value, since the current value
// is the value for the next regular release.
private const val versionPatch = 4
private const val versionPatch = 5
object Versions {
const val VERSION_CODE = 4_000_000 + versionMajor * 1_00_00 + versionMinor * 1_00 + versionPatch

View file

@ -48,6 +48,9 @@ class KonsistClassNameTest {
Konsist.scopeFromProduction()
.classes()
.withAllParentsOf(PreviewParameterProvider::class)
.withoutName(
"AspectRatioProvider",
)
.also {
// Check that classes are actually found
assertThat(it.size).isGreaterThan(100)

View file

@ -101,8 +101,10 @@ class KonsistPreviewTest {
"SasEmojisPreview",
"SecureBackupSetupViewChangePreview",
"SelectedUserCannotRemovePreview",
"TextComposerAddCaptionPreview",
"TextComposerCaptionPreview",
"TextComposerEditPreview",
"TextComposerEditCaptionPreview",
"TextComposerFormattingPreview",
"TextComposerLinkDialogCreateLinkPreview",
"TextComposerLinkDialogCreateLinkWithoutTextPreview",

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:94ee32aad0979cbc11d1ef7dbb23fbed8425778a9e28f8742a7d219d8bdab379
oid sha256:480dcf4628c371f8df8b6a26f73f0a5ed45e9ca89baf9f4ab52bd896ed1cc4d7
size 77593

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5e85f874e950c2ae755a31bc2dac2115ef30e052a1183112badd50e7066d861c
oid sha256:96333cfda74bc44b7f9f95eab383ab26857b07d68ed30255ccb1ef3ae1558a4c
size 78110

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d175ae95fed4ac24e9b506678c4ee1e235c3c8405915498f7f97db0764e5470c
size 7571
oid sha256:adb68fcd10b66e37f774560e59991bf6488043d4e1b66e6b1f19a648c90a6333
size 7572

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0db80941c8c981d3f66bc6cd9364f0f8fd5b1e7033f81da46cdffe478f97c748
oid sha256:72dbe69b1952d3c2e00509bb1a5e0493baa30c0ef4e444f652f3fd7f81a8eccd
size 6528

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0db80941c8c981d3f66bc6cd9364f0f8fd5b1e7033f81da46cdffe478f97c748
oid sha256:72dbe69b1952d3c2e00509bb1a5e0493baa30c0ef4e444f652f3fd7f81a8eccd
size 6528

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f49822ac3f9a328dcbc9747e49ab3b3af74de42f81f833640232199d85d8d961
size 38428
oid sha256:1ae3884c6bfded545fda8dfadfc94cb4c17bf2df9556c4d33a931c3509b8a885
size 38429

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0c2187b3eb917901c6d9fac36ae44c28c7c3946a055cbf156aab3063c8981e4e
oid sha256:daba14ee51067d64e9098815b1d187a89b405b0f11b22f7fa888106cfb388fa9
size 52055

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