Merge branch 'develop' into feature/bma/fixSendQueueCrash
This commit is contained in:
commit
0261739fff
281 changed files with 1877 additions and 998 deletions
50
CHANGES.md
50
CHANGES.md
|
|
@ -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)
|
||||
========================================
|
||||
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ class MainActivity : NodeActivity() {
|
|||
|
||||
@Composable
|
||||
private fun MainNodeHost() {
|
||||
NodeHost(integrationPoint = appyxIntegrationPoint) {
|
||||
NodeHost(integrationPoint = appyxV1IntegrationPoint) {
|
||||
MainNode(
|
||||
it,
|
||||
plugins = listOf(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ internal fun MessagesViewWithIdentityChangePreview(
|
|||
onEventContentClick = { false },
|
||||
onUserDataClick = {},
|
||||
onLinkClick = {},
|
||||
onPreviewAttachments = {},
|
||||
onSendLocationClick = {},
|
||||
onCreatePollClick = {},
|
||||
onJoinCallClick = {},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -265,6 +265,7 @@ private fun TimelineItemEventContentViewWrapper(
|
|||
eventSink = { },
|
||||
modifier = modifier,
|
||||
onContentClick = onContentClick,
|
||||
onLongClick = null,
|
||||
onContentLayoutChange = onContentLayoutChange
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ internal fun ATimelineItemEventRow(
|
|||
timelineProtectionState = timelineProtectionState,
|
||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||
isHighlighted = isHighlighted,
|
||||
onContentClick = {},
|
||||
onEventClick = {},
|
||||
onLongClick = {},
|
||||
onLinkClick = {},
|
||||
onUserDataClick = {},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -74,7 +74,8 @@ fun TimelineItemStateEventRow(
|
|||
hideMediaContent = false,
|
||||
onShowContentClick = {},
|
||||
eventSink = eventSink,
|
||||
onContentClick = {},
|
||||
onContentClick = null,
|
||||
onLongClick = null,
|
||||
modifier = Modifier.defaultTimelineContentPadding()
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ fun aTimelineItemImageContent(
|
|||
filename = filename,
|
||||
caption = caption,
|
||||
formattedCaption = null,
|
||||
isEdited = false,
|
||||
mediaSource = MediaSource(""),
|
||||
thumbnailSource = null,
|
||||
mimeType = MimeTypes.IMAGE_JPEG,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ fun aTimelineItemVideoContent(
|
|||
filename = "Video.mp4",
|
||||
caption = null,
|
||||
formattedCaption = null,
|
||||
isEdited = false,
|
||||
thumbnailSource = null,
|
||||
blurHash = blurhash,
|
||||
aspectRatio = aspectRatio,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ fun aTimelineItemVoiceContent(
|
|||
filename = filename,
|
||||
caption = caption,
|
||||
formattedCaption = null,
|
||||
isEdited = false,
|
||||
duration = duration,
|
||||
mediaSource = mediaSource,
|
||||
mimeType = mimeType,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,5 +32,6 @@ internal fun aRustRoomPreviewInfo(
|
|||
isHistoryWorldReadable = true,
|
||||
membership = membership,
|
||||
joinRule = joinRule,
|
||||
heroes = null,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -101,8 +101,10 @@ class KonsistPreviewTest {
|
|||
"SasEmojisPreview",
|
||||
"SecureBackupSetupViewChangePreview",
|
||||
"SelectedUserCannotRemovePreview",
|
||||
"TextComposerAddCaptionPreview",
|
||||
"TextComposerCaptionPreview",
|
||||
"TextComposerEditPreview",
|
||||
"TextComposerEditCaptionPreview",
|
||||
"TextComposerFormattingPreview",
|
||||
"TextComposerLinkDialogCreateLinkPreview",
|
||||
"TextComposerLinkDialogCreateLinkWithoutTextPreview",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:94ee32aad0979cbc11d1ef7dbb23fbed8425778a9e28f8742a7d219d8bdab379
|
||||
oid sha256:480dcf4628c371f8df8b6a26f73f0a5ed45e9ca89baf9f4ab52bd896ed1cc4d7
|
||||
size 77593
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5e85f874e950c2ae755a31bc2dac2115ef30e052a1183112badd50e7066d861c
|
||||
oid sha256:96333cfda74bc44b7f9f95eab383ab26857b07d68ed30255ccb1ef3ae1558a4c
|
||||
size 78110
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d175ae95fed4ac24e9b506678c4ee1e235c3c8405915498f7f97db0764e5470c
|
||||
size 7571
|
||||
oid sha256:adb68fcd10b66e37f774560e59991bf6488043d4e1b66e6b1f19a648c90a6333
|
||||
size 7572
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0db80941c8c981d3f66bc6cd9364f0f8fd5b1e7033f81da46cdffe478f97c748
|
||||
oid sha256:72dbe69b1952d3c2e00509bb1a5e0493baa30c0ef4e444f652f3fd7f81a8eccd
|
||||
size 6528
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0db80941c8c981d3f66bc6cd9364f0f8fd5b1e7033f81da46cdffe478f97c748
|
||||
oid sha256:72dbe69b1952d3c2e00509bb1a5e0493baa30c0ef4e444f652f3fd7f81a8eccd
|
||||
size 6528
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f49822ac3f9a328dcbc9747e49ab3b3af74de42f81f833640232199d85d8d961
|
||||
size 38428
|
||||
oid sha256:1ae3884c6bfded545fda8dfadfc94cb4c17bf2df9556c4d33a931c3509b8a885
|
||||
size 38429
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue