Threads - first iteration (#5165)

* Initial threads support: parse `ThreadSummary`.

Replace several `isThreaded` values with `EventThreadInfo`, which contains the info about the event either being the root of a thread or part of it.

* Add `Threaded` timeline mode

* Add a `liveTimeline` parameter to `TimelineController`'s  constructor. This way we can customise which timeline will be used as the 'live' one. Also add `@LiveTimeline` DI qualifier for the actual live timeline of the room.

* Create `ThreadedMessagesNode`. Allow opening a thread in a separate screen.

* Add the callbacks for the list menu actions - even if they're the wrong ones and will send the data to the room instead

* Send attachments and location in threads

* Fix polls in threads, add support for sending voice messages in threads

* Display thread summaries only when the feature flag is enabled

* Use 'Reply' instead of 'Reply in thread' when in threaded timeline mode

* Remove incorrect usage of `Timeline` in `MessageComposerPresenter`. This led to replies to threaded events not appearing as actual replies.

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Jorge Martin Espinosa 2025-08-19 15:35:48 +02:00 committed by GitHub
parent cc10ba41fd
commit 35928e3630
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
119 changed files with 1520 additions and 339 deletions

View file

@ -38,6 +38,7 @@ import io.element.android.features.messages.impl.forward.ForwardMessagesNode
import io.element.android.features.messages.impl.pinned.PinnedEventsTimelineProvider
import io.element.android.features.messages.impl.pinned.list.PinnedMessagesListNode
import io.element.android.features.messages.impl.report.ReportMessageNode
import io.element.android.features.messages.impl.threads.ThreadedMessagesNode
import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNode
import io.element.android.features.messages.impl.timeline.model.TimelineItem
@ -65,6 +66,7 @@ import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.media.MediaSource
@ -139,7 +141,7 @@ class MessagesFlowNode @AssistedInject constructor(
) : NavTarget
@Parcelize
data class AttachmentPreview(val attachment: Attachment) : NavTarget
data class AttachmentPreview(val timelineMode: Timeline.Mode, val attachment: Attachment) : NavTarget
@Parcelize
data class LocationViewer(val location: Location, val description: String?) : NavTarget
@ -154,19 +156,22 @@ class MessagesFlowNode @AssistedInject constructor(
data class ReportMessage(val eventId: EventId, val senderId: UserId) : NavTarget
@Parcelize
data object SendLocation : NavTarget
data class SendLocation(val timelineMode: Timeline.Mode) : NavTarget
@Parcelize
data object CreatePoll : NavTarget
data class CreatePoll(val timelineMode: Timeline.Mode) : NavTarget
@Parcelize
data class EditPoll(val eventId: EventId) : NavTarget
data class EditPoll(val timelineMode: Timeline.Mode, val eventId: EventId) : NavTarget
@Parcelize
data object PinnedMessagesList : NavTarget
@Parcelize
data object KnockRequestsList : NavTarget
@Parcelize
data class OpenThread(val threadRootId: ThreadId, val focusedEventId: EventId?) : NavTarget
}
private val callbacks = plugins<MessagesEntryPoint.Callback>()
@ -211,15 +216,18 @@ class MessagesFlowNode @AssistedInject constructor(
callbacks.forEach { it.onRoomDetailsClick() }
}
override fun onEventClick(isLive: Boolean, event: TimelineItem.Event): Boolean {
override fun onEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean {
return processEventClick(
timelineMode = if (isLive) Timeline.Mode.LIVE else Timeline.Mode.FOCUSED_ON_EVENT,
timelineMode = timelineMode,
event = event,
)
}
override fun onPreviewAttachments(attachments: ImmutableList<Attachment>) {
backstack.push(NavTarget.AttachmentPreview(attachments.first()))
backstack.push(NavTarget.AttachmentPreview(
attachment = attachments.first(),
timelineMode = Timeline.Mode.Live,
))
}
override fun onUserDataClick(userId: UserId) {
@ -243,15 +251,15 @@ class MessagesFlowNode @AssistedInject constructor(
}
override fun onSendLocationClick() {
backstack.push(NavTarget.SendLocation)
backstack.push(NavTarget.SendLocation(Timeline.Mode.Live))
}
override fun onCreatePollClick() {
backstack.push(NavTarget.CreatePoll)
backstack.push(NavTarget.CreatePoll(Timeline.Mode.Live))
}
override fun onEditPollClick(eventId: EventId) {
backstack.push(NavTarget.EditPoll(eventId))
backstack.push(NavTarget.EditPoll(Timeline.Mode.Live, eventId))
}
override fun onJoinCallClick(roomId: RoomId) {
@ -270,6 +278,10 @@ class MessagesFlowNode @AssistedInject constructor(
override fun onViewKnockRequests() {
backstack.push(NavTarget.KnockRequestsList)
}
override fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?) {
backstack.push(NavTarget.OpenThread(threadRootId, focusedEventId))
}
}
val inputs = MessagesNode.Inputs(focusedEventId = navTarget.focusedEventId)
createNode<MessagesNode>(buildContext, listOf(callback, inputs))
@ -298,7 +310,10 @@ class MessagesFlowNode @AssistedInject constructor(
.build()
}
is NavTarget.AttachmentPreview -> {
val inputs = AttachmentsPreviewNode.Inputs(navTarget.attachment)
val inputs = AttachmentsPreviewNode.Inputs(
attachment = navTarget.attachment,
timelineMode = navTarget.timelineMode,
)
createNode<AttachmentsPreviewNode>(buildContext, listOf(inputs))
}
is NavTarget.LocationViewer -> {
@ -327,24 +342,34 @@ class MessagesFlowNode @AssistedInject constructor(
val inputs = ReportMessageNode.Inputs(navTarget.eventId, navTarget.senderId)
createNode<ReportMessageNode>(buildContext, listOf(inputs))
}
NavTarget.SendLocation -> {
sendLocationEntryPoint.createNode(this, buildContext)
is NavTarget.SendLocation -> {
sendLocationEntryPoint
.builder(navTarget.timelineMode)
.build(this, buildContext)
}
NavTarget.CreatePoll -> {
is NavTarget.CreatePoll -> {
createPollEntryPoint.nodeBuilder(this, buildContext)
.params(CreatePollEntryPoint.Params(mode = CreatePollMode.NewPoll))
.params(CreatePollEntryPoint.Params(
timelineMode = navTarget.timelineMode,
mode = CreatePollMode.NewPoll
))
.build()
}
is NavTarget.EditPoll -> {
createPollEntryPoint.nodeBuilder(this, buildContext)
.params(CreatePollEntryPoint.Params(mode = CreatePollMode.EditPoll(eventId = navTarget.eventId)))
.params(
CreatePollEntryPoint.Params(
timelineMode = navTarget.timelineMode,
mode = CreatePollMode.EditPoll(eventId = navTarget.eventId)
)
)
.build()
}
NavTarget.PinnedMessagesList -> {
val callback = object : PinnedMessagesListNode.Callback {
override fun onEventClick(event: TimelineItem.Event) {
processEventClick(
timelineMode = Timeline.Mode.PINNED_EVENTS,
timelineMode = Timeline.Mode.PinnedEvents,
event = event,
)
}
@ -377,6 +402,69 @@ class MessagesFlowNode @AssistedInject constructor(
NavTarget.KnockRequestsList -> {
knockRequestsListEntryPoint.createNode(this, buildContext)
}
is NavTarget.OpenThread -> {
val inputs = ThreadedMessagesNode.Inputs(
threadRootEventId = navTarget.threadRootId,
focusedEventId = navTarget.focusedEventId,
)
val callback = object : ThreadedMessagesNode.Callback {
override fun onEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean {
return processEventClick(
timelineMode = timelineMode,
event = event,
)
}
override fun onPreviewAttachments(attachments: ImmutableList<Attachment>) {
backstack.push(NavTarget.AttachmentPreview(
attachment = attachments.first(),
timelineMode = Timeline.Mode.Thread(navTarget.threadRootId)
))
}
override fun onUserDataClick(userId: UserId) {
callbacks.forEach { it.onUserDataClick(userId) }
}
override fun onPermalinkClick(data: PermalinkData) {
callbacks.forEach { it.onPermalinkClick(data, pushToBackstack = true) }
}
override fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo))
}
override fun onForwardEventClick(eventId: EventId) {
backstack.push(NavTarget.ForwardEvent(eventId, fromPinnedEvents = false))
}
override fun onReportMessage(eventId: EventId, senderId: UserId) {
backstack.push(NavTarget.ReportMessage(eventId, senderId))
}
override fun onSendLocationClick() {
backstack.push(NavTarget.SendLocation(Timeline.Mode.Thread(navTarget.threadRootId)))
}
override fun onCreatePollClick() {
backstack.push(NavTarget.CreatePoll(Timeline.Mode.Thread(navTarget.threadRootId)))
}
override fun onEditPollClick(eventId: EventId) {
backstack.push(NavTarget.EditPoll(Timeline.Mode.Thread(navTarget.threadRootId), eventId))
}
override fun onJoinCallClick(roomId: RoomId) {
val callType = CallType.RoomCall(
sessionId = matrixClient.sessionId,
roomId = roomId,
)
analyticsService.captureInteraction(Interaction.Name.MobileRoomCallButton)
elementCallEntryPoint.startCall(callType)
}
}
createNode<ThreadedMessagesNode>(buildContext, listOf(inputs, callback))
}
}
}

View file

@ -10,6 +10,7 @@ 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.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import kotlinx.collections.immutable.ImmutableList
@ -21,4 +22,5 @@ interface MessagesNavigator {
fun onEditPollClick(eventId: EventId)
fun onPreviewAttachment(attachments: ImmutableList<Attachment>)
fun onNavigateToRoom(roomId: RoomId, serverNames: List<String>)
fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?)
}

View file

@ -34,6 +34,7 @@ import io.element.android.features.messages.impl.actionlist.model.TimelineItemAc
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.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.di.LocalTimelineItemPresenterFactories
@ -55,12 +56,14 @@ import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.analytics.toAnalyticsViewRoom
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.alias.matches
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.mediaplayer.api.MediaPlayer
import io.element.android.libraries.ui.strings.CommonStrings
@ -75,9 +78,8 @@ class MessagesNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
@ApplicationContext private val context: Context,
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
private val room: BaseRoom,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
private val room: JoinedRoom,
private val analyticsService: AnalyticsService,
messageComposerPresenterFactory: MessageComposerPresenter.Factory,
timelinePresenterFactory: TimelinePresenter.Factory,
@ -89,11 +91,16 @@ class MessagesNode @AssistedInject constructor(
private val knockRequestsBannerRenderer: KnockRequestsBannerRenderer,
private val roomMemberModerationRenderer: RoomMemberModerationRenderer,
) : Node(buildContext, plugins = plugins), MessagesNavigator {
private val timelineController = TimelineController(room, room.liveTimeline)
private val presenter = presenterFactory.create(
navigator = this,
composerPresenter = messageComposerPresenterFactory.create(this),
timelinePresenter = timelinePresenterFactory.create(this),
actionListPresenter = actionListPresenterFactory.create(TimelineItemActionPostProcessor.Default)
composerPresenter = messageComposerPresenterFactory.create(timelineController, this),
timelinePresenter = timelinePresenterFactory.create(timelineController = timelineController, this),
actionListPresenter = actionListPresenterFactory.create(
postProcessor = TimelineItemActionPostProcessor.Default,
timelineMode = timelineController.mainTimelineMode()
),
timelineController = timelineController,
)
private val callbacks = plugins<Callback>()
@ -103,7 +110,7 @@ class MessagesNode @AssistedInject constructor(
interface Callback : Plugin {
fun onRoomDetailsClick()
fun onEventClick(isLive: Boolean, event: TimelineItem.Event): Boolean
fun onEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean
fun onPreviewAttachments(attachments: ImmutableList<Attachment>)
fun onUserDataClick(userId: UserId)
fun onPermalinkClick(data: PermalinkData)
@ -116,6 +123,7 @@ class MessagesNode @AssistedInject constructor(
fun onJoinCallClick(roomId: RoomId)
fun onViewAllPinnedEvents()
fun onViewKnockRequests()
fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?)
}
override fun onBuilt() {
@ -134,12 +142,12 @@ class MessagesNode @AssistedInject constructor(
callbacks.forEach { it.onRoomDetailsClick() }
}
private fun onEventClick(isLive: Boolean, event: TimelineItem.Event): Boolean {
private fun onEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean {
// Note: cannot use `callbacks.all { it.onEventClick(event) }` because:
// - if callbacks is empty, it will return true and we want to return false.
// - if a callback returns false, the other callback will not be invoked.
return callbacks.takeIf { it.isNotEmpty() }
?.map { it.onEventClick(isLive, event) }
?.map { it.onEventClick(timelineMode, event) }
?.all { it }
.orFalse()
}
@ -223,6 +231,10 @@ class MessagesNode @AssistedInject constructor(
}
}
override fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?) {
callbacks.forEach { it.onOpenThread(threadRootId, focusedEventId) }
}
private fun onViewAllPinnedMessagesClick() {
callbacks.forEach { it.onViewAllPinnedEvents() }
}
@ -265,7 +277,18 @@ class MessagesNode @AssistedInject constructor(
state = state,
onBackClick = this::navigateUp,
onRoomDetailsClick = this::onRoomDetailsClick,
onEventContentClick = this::onEventClick,
onEventContentClick = { isLive, event ->
if (isLive) {
onEventClick(timelineController.mainTimelineMode(), event)
} else {
val detachedTimelineMode = timelineController.detachedTimelineMode()
if (detachedTimelineMode != null) {
onEventClick(detachedTimelineMode, event)
} else {
false
}
}
},
onUserDataClick = this::onUserDataClick,
onLinkClick = { url, customTab -> onLinkClick(activity, isDark, url, state.timelineState.eventSink, customTab) },
onSendLocationClick = this::onSendLocationClick,

View file

@ -48,7 +48,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
import io.element.android.features.messages.impl.voicemessages.composer.DefaultVoiceMessageComposerPresenter
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
@ -93,7 +93,7 @@ class MessagesPresenter @AssistedInject constructor(
@Assisted private val navigator: MessagesNavigator,
private val room: JoinedRoom,
@Assisted private val composerPresenter: Presenter<MessageComposerState>,
private val voiceMessageComposerPresenter: Presenter<VoiceMessageComposerState>,
voiceMessageComposerPresenterFactory: DefaultVoiceMessageComposerPresenter.Factory,
@Assisted private val timelinePresenter: Presenter<TimelineState>,
private val timelineProtectionPresenter: Presenter<TimelineProtectionState>,
private val identityChangeStatePresenter: Presenter<IdentityChangeState>,
@ -111,7 +111,7 @@ class MessagesPresenter @AssistedInject constructor(
private val clipboardHelper: ClipboardHelper,
private val htmlConverterProvider: HtmlConverterProvider,
private val buildMeta: BuildMeta,
private val timelineController: TimelineController,
@Assisted private val timelineController: TimelineController,
private val permalinkParser: PermalinkParser,
private val analyticsService: AnalyticsService,
private val encryptionService: EncryptionService,
@ -123,9 +123,14 @@ class MessagesPresenter @AssistedInject constructor(
composerPresenter: Presenter<MessageComposerState>,
timelinePresenter: Presenter<TimelineState>,
actionListPresenter: Presenter<ActionListState>,
timelineController: TimelineController,
): MessagesPresenter
}
private val voiceMessageComposerPresenter = voiceMessageComposerPresenterFactory.create(
timelineMode = timelineController.mainTimelineMode()
)
@Composable
override fun present(): MessagesState {
htmlConverterProvider.Update()
@ -145,9 +150,8 @@ class MessagesPresenter @AssistedInject constructor(
val pinnedMessagesBannerState = pinnedMessagesBannerPresenter.present()
val roomCallState = roomCallStatePresenter.present()
val roomMemberModerationState = roomMemberModerationPresenter.present()
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val userEventPermissions by userEventPermissions(syncUpdateFlow.value)
val userEventPermissions by userEventPermissions(roomInfo)
val roomAvatar by remember {
derivedStateOf { roomInfo.avatarData() }
@ -264,8 +268,13 @@ class MessagesPresenter @AssistedInject constructor(
}
@Composable
private fun userEventPermissions(updateKey: Long): State<UserEventPermissions> {
return produceState(UserEventPermissions.DEFAULT, key1 = updateKey) {
private fun userEventPermissions(roomInfo: RoomInfo): State<UserEventPermissions> {
val key = if (roomInfo.privilegedCreatorRole && roomInfo.creators.contains(room.sessionId)) {
Long.MAX_VALUE
} else {
roomInfo.roomPowerLevels?.hashCode() ?: 0L
}
return produceState(UserEventPermissions.DEFAULT, key1 = key) {
value = UserEventPermissions(
canSendMessage = room.canSendMessage(type = MessageEventType.ROOM_MESSAGE).getOrElse { true },
canSendReaction = room.canSendMessage(type = MessageEventType.REACTION).getOrElse { true },

View file

@ -8,6 +8,7 @@
package io.element.android.features.messages.impl
import androidx.compose.runtime.Immutable
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerState
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
import io.element.android.features.messages.impl.link.LinkState
@ -18,7 +19,6 @@ import io.element.android.features.messages.impl.timeline.components.customreact
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.protection.TimelineProtectionState
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
import io.element.android.libraries.architecture.AsyncData

View file

@ -8,6 +8,9 @@
package io.element.android.features.messages.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerState
import io.element.android.features.messages.api.timeline.voicemessages.composer.aVoiceMessageComposerState
import io.element.android.features.messages.api.timeline.voicemessages.composer.aVoiceMessagePreviewState
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
@ -31,9 +34,6 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessageComposerState
import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessagePreviewState
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.features.roomcall.api.anOngoingCallState
@ -43,8 +43,10 @@ import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.aTextEditorStateRich
import kotlinx.collections.immutable.persistentListOf
@ -84,6 +86,10 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
aMessagesState(roomName = "A DM with a very looong name", dmUserVerificationState = IdentityState.Verified),
aMessagesState(roomName = "A DM with a very looong name", dmUserVerificationState = IdentityState.VerificationViolation),
aMessagesState(successorRoom = SuccessorRoom(RoomId("!id:domain"), null)),
aMessagesState(timelineState = aTimelineState(
timelineMode = Timeline.Mode.Thread(threadRootId = ThreadId("\$a-thread-id")),
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
)),
)
}

View file

@ -48,6 +48,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerEvents
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
@ -73,7 +74,6 @@ import io.element.android.features.messages.impl.timeline.components.reactionsum
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheet
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvents
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerEvents
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageSendingFailedDialog
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
@ -105,6 +105,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.textcomposer.model.TextEditorState
import io.element.android.libraries.ui.strings.CommonStrings
@ -196,17 +197,21 @@ fun MessagesView(
topBar = {
Column {
ConnectivityIndicatorView(isOnline = state.hasNetworkConnection)
MessagesViewTopBar(
roomName = state.roomName,
roomAvatar = state.roomAvatar,
isTombstoned = state.isTombstoned,
heroes = state.heroes,
roomCallState = state.roomCallState,
dmUserIdentityState = state.dmUserVerificationState,
onBackClick = { hidingKeyboard { onBackClick() } },
onRoomDetailsClick = { hidingKeyboard { onRoomDetailsClick() } },
onJoinCallClick = onJoinCallClick,
)
if (state.timelineState.timelineMode is Timeline.Mode.Thread) {
ThreadTopBar(onBackClick = onBackClick)
} else {
MessagesViewTopBar(
roomName = state.roomName,
roomAvatar = state.roomAvatar,
isTombstoned = state.isTombstoned,
heroes = state.heroes,
roomCallState = state.roomCallState,
dmUserIdentityState = state.dmUserVerificationState,
onBackClick = { hidingKeyboard { onBackClick() } },
onRoomDetailsClick = { hidingKeyboard { onRoomDetailsClick() } },
onJoinCallClick = onJoinCallClick,
)
}
}
},
content = { padding ->
@ -414,23 +419,26 @@ private fun MessagesViewContent(
onJoinCallClick = onJoinCallClick,
nestedScrollConnection = scrollBehavior.nestedScrollConnection,
)
AnimatedVisibility(
visible = state.pinnedMessagesBannerState is PinnedMessagesBannerState.Visible && scrollBehavior.isVisible,
enter = expandVertically(),
exit = shrinkVertically(),
) {
fun focusOnPinnedEvent(eventId: EventId) {
state.timelineState.eventSink(
TimelineEvents.FocusOnEvent(eventId = eventId, debounce = FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS.milliseconds)
if (state.timelineState.timelineMode !is Timeline.Mode.Thread) {
AnimatedVisibility(
visible = state.pinnedMessagesBannerState is PinnedMessagesBannerState.Visible && scrollBehavior.isVisible,
enter = expandVertically(),
exit = shrinkVertically(),
) {
fun focusOnPinnedEvent(eventId: EventId) {
state.timelineState.eventSink(
TimelineEvents.FocusOnEvent(eventId = eventId, debounce = FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS.milliseconds)
)
}
PinnedMessagesBannerView(
state = state.pinnedMessagesBannerState,
onClick = ::focusOnPinnedEvent,
onViewAllClick = onViewAllPinnedMessagesClick,
)
}
PinnedMessagesBannerView(
state = state.pinnedMessagesBannerState,
onClick = ::focusOnPinnedEvent,
onViewAllClick = onViewAllPinnedMessagesClick,
)
knockRequestsBannerView()
}
knockRequestsBannerView()
}
}
}
@ -540,6 +548,21 @@ private fun MessagesViewTopBar(
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ThreadTopBar(
onBackClick: () -> Unit,
) {
TopAppBar(
navigationIcon = {
BackButton(onClick = onBackClick)
},
title = {
Text(stringResource(CommonStrings.common_thread))
}
)
}
@Composable
private fun RoomAvatarAndNameRow(
roomName: String?,

View file

@ -41,6 +41,7 @@ import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@ -51,13 +52,18 @@ import kotlinx.coroutines.launch
interface ActionListPresenter : Presenter<ActionListState> {
interface Factory {
fun create(postProcessor: TimelineItemActionPostProcessor): ActionListPresenter
fun create(
postProcessor: TimelineItemActionPostProcessor,
timelineMode: Timeline.Mode,
): ActionListPresenter
}
}
class DefaultActionListPresenter @AssistedInject constructor(
@Assisted
private val postProcessor: TimelineItemActionPostProcessor,
@Assisted
private val timelineMode: Timeline.Mode,
private val appPreferencesStore: AppPreferencesStore,
private val room: BaseRoom,
private val userSendFailureFactory: VerifiedUserSendFailureFactory,
@ -66,7 +72,10 @@ class DefaultActionListPresenter @AssistedInject constructor(
@AssistedFactory
@ContributesBinding(RoomScope::class)
interface Factory : ActionListPresenter.Factory {
override fun create(postProcessor: TimelineItemActionPostProcessor): DefaultActionListPresenter
override fun create(
postProcessor: TimelineItemActionPostProcessor,
timelineMode: Timeline.Mode,
): DefaultActionListPresenter
}
private val comparator = TimelineItemActionComparator()
@ -150,7 +159,7 @@ class DefaultActionListPresenter @AssistedInject constructor(
val canRedact = timelineItem.isMine && usersEventPermissions.canRedactOwn || !timelineItem.isMine && usersEventPermissions.canRedactOther
return buildSet {
if (timelineItem.canBeRepliedTo && usersEventPermissions.canSendMessage) {
if (timelineItem.isThreaded) {
if (timelineMode !is Timeline.Mode.Thread && timelineItem.threadInfo.threadRootId != null) {
add(TimelineItemAction.ReplyInThread)
} else {
add(TimelineItemAction.Reply)

View file

@ -20,6 +20,7 @@ import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer
@ContributesNode(RoomScope::class)
@ -29,7 +30,10 @@ class AttachmentsPreviewNode @AssistedInject constructor(
presenterFactory: AttachmentsPreviewPresenter.Factory,
private val localMediaRenderer: LocalMediaRenderer,
) : Node(buildContext, plugins = plugins) {
data class Inputs(val attachment: Attachment) : NodeInputs
data class Inputs(
val attachment: Attachment,
val timelineMode: Timeline.Mode,
) : NodeInputs
private val inputs: Inputs = inputs()
@ -39,6 +43,7 @@ class AttachmentsPreviewNode @AssistedInject constructor(
private val presenter = presenterFactory.create(
attachment = inputs.attachment,
timelineMode = inputs.timelineMode,
onDoneListener = onDoneListener,
)

View file

@ -33,6 +33,7 @@ import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
@ -50,7 +51,8 @@ import timber.log.Timber
class AttachmentsPreviewPresenter @AssistedInject constructor(
@Assisted private val attachment: Attachment,
@Assisted private val onDoneListener: OnDoneListener,
private val mediaSender: MediaSender,
@Assisted private val timelineMode: Timeline.Mode,
mediaSenderFactory: MediaSender.Factory,
private val permalinkBuilder: PermalinkBuilder,
private val temporaryUriDeleter: TemporaryUriDeleter,
private val mediaOptimizationSelectorPresenterFactory: MediaOptimizationSelectorPresenter.Factory,
@ -61,10 +63,13 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
interface Factory {
fun create(
attachment: Attachment,
timelineMode: Timeline.Mode,
onDoneListener: OnDoneListener,
): AttachmentsPreviewPresenter
}
private val mediaSender = mediaSenderFactory.create(timelineMode)
@Composable
override fun present(): AttachmentsPreviewState {
val coroutineScope = rememberCoroutineScope()

View file

@ -28,14 +28,12 @@ import io.element.android.features.messages.impl.timeline.protection.TimelinePro
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.typing.TypingNotificationPresenter
import io.element.android.features.messages.impl.typing.TypingNotificationState
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.RoomScope
@ContributesTo(RoomScope::class)
@Module
interface MessagesModule {
interface MessagesBindsModule {
@Binds
fun bindPinnedMessagesBannerPresenter(presenter: PinnedMessagesBannerPresenter): Presenter<PinnedMessagesBannerState>
@ -51,9 +49,6 @@ interface MessagesModule {
@Binds
fun bindLinkPresenter(presenter: LinkPresenter): Presenter<LinkState>
@Binds
fun bindVoiceMessageComposerPresenter(presenter: VoiceMessageComposerPresenter): Presenter<VoiceMessageComposerState>
@Binds
fun bindCustomReactionPresenter(presenter: CustomReactionPresenter): Presenter<CustomReactionState>

View file

@ -0,0 +1,24 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.di
import com.squareup.anvil.annotations.ContributesTo
import dagger.Module
import dagger.Provides
import io.element.android.features.messages.impl.timeline.di.LiveTimeline
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
@ContributesTo(RoomScope::class)
@Module
object MessagesProvidesModule {
@Provides
@LiveTimeline
fun provideLiveTimeline(joinedRoom: JoinedRoom): Timeline = joinedRoom.liveTimeline
}

View file

@ -97,13 +97,13 @@ import io.element.android.libraries.core.mimetype.MimeTypes.Any as AnyMimeTypes
class MessageComposerPresenter @AssistedInject constructor(
@Assisted private val navigator: MessagesNavigator,
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
@Assisted private val timelineController: TimelineController,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
private val room: JoinedRoom,
private val mediaPickerProvider: PickerProvider,
private val sessionPreferencesStore: SessionPreferencesStore,
private val localMediaFactory: LocalMediaFactory,
private val mediaSender: MediaSender,
private val mediaSenderFactory: MediaSender.Factory,
private val snackbarDispatcher: SnackbarDispatcher,
private val analyticsService: AnalyticsService,
private val locationService: LocationService,
@ -113,7 +113,6 @@ class MessageComposerPresenter @AssistedInject constructor(
private val permalinkParser: PermalinkParser,
private val permalinkBuilder: PermalinkBuilder,
permissionsPresenterFactory: PermissionsPresenter.Factory,
private val timelineController: TimelineController,
private val draftService: ComposerDraftService,
private val mentionSpanProvider: MentionSpanProvider,
private val pillificationHelper: TextPillificationHelper,
@ -122,9 +121,11 @@ class MessageComposerPresenter @AssistedInject constructor(
) : Presenter<MessageComposerState> {
@AssistedFactory
interface Factory {
fun create(navigator: MessagesNavigator): MessageComposerPresenter
fun create(timelineController: TimelineController, navigator: MessagesNavigator): MessageComposerPresenter
}
private val mediaSender = mediaSenderFactory.create(timelineMode = timelineController.mainTimelineMode())
private val cameraPermissionPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA)
private var pendingEvent: MessageComposerEvents? = null
private val suggestionSearchTrigger = MutableStateFlow<Suggestion?>(null)
@ -423,11 +424,13 @@ class MessageComposerPresenter @AssistedInject constructor(
resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = capturedMode is MessageComposerMode.Edit)
when (capturedMode) {
is MessageComposerMode.Attachment,
is MessageComposerMode.Normal -> room.liveTimeline.sendMessage(
body = message.markdown,
htmlBody = message.html,
intentionalMentions = message.intentionalMentions
)
is MessageComposerMode.Normal -> timelineController.invokeOnCurrentTimeline {
sendMessage(
body = message.markdown,
htmlBody = message.html,
intentionalMentions = message.intentionalMentions
)
}
is MessageComposerMode.Edit -> {
timelineController.invokeOnCurrentTimeline {
// First try to edit the message in the current timeline

View file

@ -17,10 +17,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerEvents
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerStateProvider
import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessageComposerState
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerEvents
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerState
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerStateProvider
import io.element.android.features.messages.api.timeline.voicemessages.composer.aVoiceMessageComposerState
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.textcomposer.TextComposer

View file

@ -32,6 +32,7 @@ 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.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.strings.CommonStrings
@ -56,7 +57,10 @@ class PinnedMessagesListNode @AssistedInject constructor(
private val presenter = presenterFactory.create(
navigator = this,
actionListPresenter = actionListPresenterFactory.create(PinnedMessagesListTimelineActionPostProcessor())
actionListPresenter = actionListPresenterFactory.create(
postProcessor = PinnedMessagesListTimelineActionPostProcessor(),
timelineMode = Timeline.Mode.PinnedEvents,
)
)
private val callbacks = plugins<Callback>()

View file

@ -39,6 +39,8 @@ 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.annotations.SessionCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.powerlevels.canPinUnpin
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther
@ -71,6 +73,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
private val analyticsService: AnalyticsService,
private val featureFlagService: FeatureFlagService,
) : Presenter<PinnedMessagesListState> {
@AssistedFactory
interface Factory {
@ -115,6 +118,8 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val userEventPermissions by userEventPermissions(syncUpdateFlow.value)
val displayThreadSummaries by featureFlagService.isFeatureEnabledFlow(FeatureFlags.HideThreadedEvents).collectAsState(false)
var pinnedMessageItems by remember {
mutableStateOf<AsyncData<ImmutableList<TimelineItem>>>(AsyncData.Uninitialized)
}
@ -134,6 +139,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
timelineRoomInfo = timelineRoomInfo,
timelineProtectionState = timelineProtectionState,
linkState = linkState,
displayThreadSummaries = displayThreadSummaries,
userEventPermissions = userEventPermissions,
timelineItems = pinnedMessageItems,
eventSink = ::handleEvents
@ -230,6 +236,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
private fun pinnedMessagesListState(
timelineRoomInfo: TimelineRoomInfo,
timelineProtectionState: TimelineProtectionState,
displayThreadSummaries: Boolean,
linkState: LinkState,
userEventPermissions: UserEventPermissions,
timelineItems: AsyncData<ImmutableList<TimelineItem>>,
@ -246,6 +253,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
PinnedMessagesListState.Filled(
timelineRoomInfo = timelineRoomInfo,
timelineProtectionState = timelineProtectionState,
displayThreadSummaries = displayThreadSummaries,
linkState = linkState,
userEventPermissions = userEventPermissions,
timelineItems = timelineItems.data,

View file

@ -33,6 +33,7 @@ sealed interface PinnedMessagesListState {
val timelineItems: ImmutableList<TimelineItem>,
val actionListState: ActionListState,
val linkState: LinkState,
val displayThreadSummaries: Boolean,
val eventSink: (PinnedMessagesListEvents) -> Unit,
) : PinnedMessagesListState {
val loadedPinnedMessagesCount = timelineItems.count { timelineItem -> timelineItem is TimelineItem.Event }

View file

@ -92,6 +92,7 @@ fun aLoadedPinnedMessagesListState(
timelineItems: List<TimelineItem> = emptyList(),
actionListState: ActionListState = anActionListState(),
aUserEventPermissions: UserEventPermissions = UserEventPermissions.DEFAULT,
displayThreadSummaries: Boolean = false,
eventSink: (PinnedMessagesListEvents) -> Unit = {}
) = PinnedMessagesListState.Filled(
timelineRoomInfo = timelineRoomInfo,
@ -100,5 +101,6 @@ fun aLoadedPinnedMessagesListState(
timelineItems = timelineItems.toImmutableList(),
actionListState = actionListState,
userEventPermissions = aUserEventPermissions,
displayThreadSummaries = displayThreadSummaries,
eventSink = eventSink,
)

View file

@ -46,6 +46,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.compose.LocalAnalyticsService
@ -126,6 +127,7 @@ private fun PinnedMessagesListContent(
PinnedMessagesListState.Empty -> PinnedMessagesListEmpty()
is PinnedMessagesListState.Filled -> PinnedMessagesListLoaded(
state = state,
displayThreadSummaries = state.displayThreadSummaries,
onEventClick = onEventClick,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
@ -163,6 +165,7 @@ private fun PinnedMessagesListEmpty(
@Composable
private fun PinnedMessagesListLoaded(
state: PinnedMessagesListState.Filled,
displayThreadSummaries: Boolean,
onEventClick: (event: TimelineItem.Event) -> Unit,
onUserDataClick: (MatrixUser) -> Unit,
onLinkClick: (Link) -> Unit,
@ -210,6 +213,7 @@ private fun PinnedMessagesListLoaded(
) { timelineItem ->
TimelineItemRow(
timelineItem = timelineItem,
timelineMode = Timeline.Mode.PinnedEvents,
timelineRoomInfo = state.timelineRoomInfo,
renderReadReceipts = false,
timelineProtectionState = state.timelineProtectionState,
@ -222,6 +226,7 @@ private fun PinnedMessagesListLoaded(
onLinkLongClick = onLinkLongClick,
onContentClick = onEventClick,
onLongClick = ::onMessageLongClick,
displayThreadSummaries = displayThreadSummaries,
inReplyToClick = {},
onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> },

View file

@ -0,0 +1,302 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.threads
import android.app.Activity
import android.content.Context
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.Lifecycle
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.MessagesNavigator
import io.element.android.features.messages.impl.MessagesPresenter
import io.element.android.features.messages.impl.MessagesView
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionPostProcessor
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.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.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.androidutils.system.openUrlInExternalApp
import io.element.android.libraries.androidutils.system.toast
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.analytics.toAnalyticsViewRoom
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.CreateTimelineParams
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.alias.matches
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.mediaplayer.api.MediaPlayer
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
@ContributesNode(RoomScope::class)
class ThreadedMessagesNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
@ApplicationContext private val context: Context,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
private val room: JoinedRoom,
private val analyticsService: AnalyticsService,
messageComposerPresenterFactory: MessageComposerPresenter.Factory,
timelinePresenterFactory: TimelinePresenter.Factory,
presenterFactory: MessagesPresenter.Factory,
actionListPresenterFactory: ActionListPresenter.Factory,
private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
private val mediaPlayer: MediaPlayer,
private val permalinkParser: PermalinkParser,
) : Node(buildContext, plugins = plugins), MessagesNavigator {
private val callbacks = plugins<Callback>()
data class Inputs(
val threadRootEventId: ThreadId,
val focusedEventId: EventId?,
) : NodeInputs
private val inputs = inputs<Inputs>()
// TODO use a loading state node to preload this instead of using `runBlocking`
private val threadedTimeline = runBlocking { room.createTimeline(CreateTimelineParams.Threaded(threadRootEventId = inputs.threadRootEventId)).getOrThrow() }
private val timelineController = TimelineController(room, threadedTimeline)
private val presenter = presenterFactory.create(
navigator = this,
composerPresenter = messageComposerPresenterFactory.create(timelineController, this),
timelinePresenter = timelinePresenterFactory.create(timelineController = timelineController, this),
// TODO add special processor for threaded timeline
actionListPresenter = actionListPresenterFactory.create(
postProcessor = TimelineItemActionPostProcessor.Default,
timelineMode = timelineController.mainTimelineMode(),
),
timelineController = timelineController,
)
interface Callback : Plugin {
fun onEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean
fun onPreviewAttachments(attachments: ImmutableList<Attachment>)
fun onUserDataClick(userId: UserId)
fun onPermalinkClick(data: PermalinkData)
fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo)
fun onForwardEventClick(eventId: EventId)
fun onReportMessage(eventId: EventId, senderId: UserId)
fun onSendLocationClick()
fun onCreatePollClick()
fun onEditPollClick(eventId: EventId)
fun onJoinCallClick(roomId: RoomId)
}
override fun onBuilt() {
super.onBuilt()
lifecycle.subscribe(
onCreate = {
sessionCoroutineScope.launch { analyticsService.capture(room.toAnalyticsViewRoom()) }
},
onDestroy = {
mediaPlayer.close()
}
)
}
private fun onEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean {
// Note: cannot use `callbacks.all { it.onEventClick(event) }` because:
// - if callbacks is empty, it will return true and we want to return false.
// - if a callback returns false, the other callback will not be invoked.
return callbacks.takeIf { it.isNotEmpty() }
?.map { it.onEventClick(timelineMode, event) }
?.all { it }
.orFalse()
}
private fun onUserDataClick(userId: UserId) {
callbacks.forEach { it.onUserDataClick(userId) }
}
private fun onLinkClick(
activity: Activity,
darkTheme: Boolean,
url: String,
eventSink: (TimelineEvents) -> Unit,
customTab: Boolean
) {
when (val permalink = permalinkParser.parse(url)) {
is PermalinkData.UserLink -> {
// Open the room member profile, it will fallback to
// the user profile if the user is not in the room
callbacks.forEach { it.onUserDataClick(permalink.userId) }
}
is PermalinkData.RoomLink -> {
handleRoomLinkClick(permalink, eventSink)
}
is PermalinkData.FallbackLink -> {
if (customTab) {
activity.openUrlInChromeCustomTab(null, darkTheme, url)
} else {
activity.openUrlInExternalApp(url)
}
}
is PermalinkData.RoomEmailInviteLink -> {
activity.openUrlInChromeCustomTab(null, darkTheme, url)
}
}
}
private fun handleRoomLinkClick(
roomLink: PermalinkData.RoomLink,
eventSink: (TimelineEvents) -> Unit,
) {
if (room.matches(roomLink.roomIdOrAlias)) {
val eventId = roomLink.eventId
if (eventId != null) {
eventSink(TimelineEvents.FocusOnEvent(eventId))
} else {
// Click on the same room, ignore
displaySameRoomToast()
}
} else {
callbacks.forEach { it.onPermalinkClick(roomLink) }
}
}
override fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
callbacks.forEach { it.onShowEventDebugInfoClick(eventId, debugInfo) }
}
override fun onForwardEventClick(eventId: EventId) {
callbacks.forEach { it.onForwardEventClick(eventId) }
}
override fun onReportContentClick(eventId: EventId, senderId: UserId) {
callbacks.forEach { it.onReportMessage(eventId, senderId) }
}
override fun onEditPollClick(eventId: EventId) {
callbacks.forEach { it.onEditPollClick(eventId) }
}
override fun onPreviewAttachment(attachments: ImmutableList<Attachment>) {
callbacks.forEach { it.onPreviewAttachments(attachments) }
}
override fun onNavigateToRoom(roomId: RoomId, serverNames: List<String>) = Unit
private fun onSendLocationClick() {
callbacks.forEach { it.onSendLocationClick() }
}
private fun onCreatePollClick() {
callbacks.forEach { it.onCreatePollClick() }
}
private fun onJoinCallClick() {
callbacks.forEach { it.onJoinCallClick(room.roomId) }
}
private fun displaySameRoomToast() {
context.toast(CommonStrings.screen_room_permalink_same_room_android)
}
override fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?) {
}
@Composable
override fun View(modifier: Modifier) {
val activity = requireNotNull(LocalActivity.current)
val isDark = ElementTheme.isLightTheme.not()
CompositionLocalProvider(
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
) {
val state = presenter.present()
OnLifecycleEvent { _, event ->
when (event) {
Lifecycle.Event.ON_PAUSE -> state.composerState.eventSink(MessageComposerEvents.SaveDraft)
else -> Unit
}
}
MessagesView(
state = state,
onBackClick = this::navigateUp,
onRoomDetailsClick = {},
onEventContentClick = { isLive, event ->
if (isLive) {
onEventClick(timelineController.mainTimelineMode(), event)
} else {
val detachedTimelineMode = timelineController.detachedTimelineMode()
if (detachedTimelineMode != null) {
onEventClick(detachedTimelineMode, event)
} else {
false
}
}
},
onUserDataClick = this::onUserDataClick,
onLinkClick = { url, customTab ->
onLinkClick(
activity,
isDark,
url,
state.timelineState.eventSink,
customTab
)
},
onSendLocationClick = this::onSendLocationClick,
onCreatePollClick = this::onCreatePollClick,
onJoinCallClick = this::onJoinCallClick,
onViewAllPinnedMessagesClick = {},
modifier = modifier,
knockRequestsBannerView = {},
)
var focusedEventId by rememberSaveable {
mutableStateOf(inputs.focusedEventId)
}
LaunchedEffect(Unit) {
focusedEventId?.also { eventId ->
state.timelineState.eventSink(TimelineEvents.FocusOnEvent(eventId))
}
// Reset the focused event id to null to avoid refocusing when restoring node.
focusedEventId = null
}
}
}
}

View file

@ -8,6 +8,7 @@
package io.element.android.features.messages.impl.timeline
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.messages.impl.timeline.di.LiveTimeline
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.core.EventId
@ -43,11 +44,12 @@ import javax.inject.Inject
@ContributesBinding(RoomScope::class, boundType = TimelineProvider::class)
class TimelineController @Inject constructor(
private val room: JoinedRoom,
@LiveTimeline private val liveTimeline: Timeline,
) : Closeable, TimelineProvider {
private val coroutineScope = CoroutineScope(SupervisorJob())
private val liveTimeline = flowOf(room.liveTimeline)
private val detachedTimeline = MutableStateFlow<Optional<Timeline>>(Optional.empty())
private val liveTimelineFlow = flowOf(liveTimeline)
private val detachedTimelineFlow = MutableStateFlow<Optional<Timeline>>(Optional.empty())
@OptIn(ExperimentalCoroutinesApi::class)
fun timelineItems(): Flow<List<MatrixTimelineItem>> {
@ -55,7 +57,13 @@ class TimelineController @Inject constructor(
}
fun isLive(): Flow<Boolean> {
return detachedTimeline.map { !it.isPresent }
return detachedTimelineFlow.map { !it.isPresent }
}
fun mainTimelineMode(): Timeline.Mode = liveTimeline.mode
fun detachedTimelineMode(): Timeline.Mode? {
return detachedTimelineFlow.value.orElse(null)?.mode
}
suspend fun invokeOnCurrentTimeline(block: suspend (Timeline.() -> Unit)) {
@ -72,7 +80,7 @@ class TimelineController @Inject constructor(
}
}
.map { newDetachedTimeline ->
detachedTimeline.getAndUpdate { current ->
detachedTimelineFlow.getAndUpdate { current ->
if (current.isPresent) {
current.get().close()
}
@ -90,7 +98,7 @@ class TimelineController @Inject constructor(
}
private fun closeDetachedTimeline() {
detachedTimeline.getAndUpdate {
detachedTimelineFlow.getAndUpdate {
when {
it.isPresent -> {
it.get().close()
@ -115,7 +123,7 @@ class TimelineController @Inject constructor(
}
}
private val currentTimelineFlow = combine(liveTimeline, detachedTimeline) { live, detached ->
private val currentTimelineFlow = combine(liveTimelineFlow, detachedTimelineFlow) { live, detached ->
when {
detached.isPresent -> detached.get()
else -> live

View file

@ -10,6 +10,7 @@ package io.element.android.features.messages.impl.timeline
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import kotlin.time.Duration
@ -31,6 +32,7 @@ sealed interface TimelineEvents {
data class ComputeVerifiedUserSendFailure(val event: TimelineItem.Event) : EventFromTimelineItem
data class ShowShieldDialog(val messageShield: MessageShield) : EventFromTimelineItem
data class LoadMore(val direction: Timeline.PaginationDirection) : EventFromTimelineItem
data class OpenThread(val threadRootEventId: ThreadId, val focusedEvent: EventId?) : EventFromTimelineItem
/**
* Navigate to the predecessor or successor room of the current room.

View file

@ -15,6 +15,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
@ -39,6 +40,8 @@ import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.room.JoinedRoom
@ -46,6 +49,7 @@ import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
import io.element.android.libraries.matrix.ui.room.canSendMessageAsState
@ -74,16 +78,20 @@ class TimelinePresenter @AssistedInject constructor(
private val sendPollResponseAction: SendPollResponseAction,
private val endPollAction: EndPollAction,
private val sessionPreferencesStore: SessionPreferencesStore,
private val timelineController: TimelineController,
@Assisted private val timelineController: TimelineController,
private val timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(),
private val resolveVerifiedUserSendFailurePresenter: Presenter<ResolveVerifiedUserSendFailureState>,
private val typingNotificationPresenter: Presenter<TypingNotificationState>,
private val roomCallStatePresenter: Presenter<RoomCallState>,
private val markAsFullyRead: MarkAsFullyRead,
private val featureFlagService: FeatureFlagService,
) : Presenter<TimelineState> {
@AssistedFactory
interface Factory {
fun create(navigator: MessagesNavigator): TimelinePresenter
fun create(
timelineController: TimelineController,
navigator: MessagesNavigator
): TimelinePresenter
}
private val timelineItemsFactory: TimelineItemsFactory = timelineItemsFactoryCreator.create(
@ -97,6 +105,9 @@ class TimelinePresenter @AssistedInject constructor(
@Composable
override fun present(): TimelineState {
val localScope = rememberCoroutineScope()
val timelineMode = remember { timelineController.mainTimelineMode() }
var focusRequestState: FocusRequestState by remember { mutableStateOf(FocusRequestState.None) }
val lastReadReceiptId = rememberSaveable { mutableStateOf<EventId?>(null) }
@ -124,9 +135,17 @@ class TimelinePresenter @AssistedInject constructor(
timelineController.isLive()
}.collectAsState(initial = true)
val displayThreadSummaries by produceState(false) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.HideThreadedEvents)
}
fun handleEvents(event: TimelineEvents) {
when (event) {
is TimelineEvents.LoadMore -> {
if (event.direction == Timeline.PaginationDirection.FORWARDS && timelineMode is Timeline.Mode.Thread) {
// Do not paginate forwards in thread mode, as it's not supported
return
}
localScope.launch {
timelineController.paginate(direction = event.direction)
}
@ -148,15 +167,21 @@ class TimelinePresenter @AssistedInject constructor(
}
}
is TimelineEvents.SelectPollAnswer -> sessionCoroutineScope.launch {
sendPollResponseAction.execute(
pollStartId = event.pollStartId,
answerId = event.answerId
)
timelineController.invokeOnCurrentTimeline {
sendPollResponseAction.execute(
timeline = this,
pollStartId = event.pollStartId,
answerId = event.answerId
)
}
}
is TimelineEvents.EndPoll -> sessionCoroutineScope.launch {
endPollAction.execute(
pollStartId = event.pollStartId,
)
timelineController.invokeOnCurrentTimeline {
endPollAction.execute(
timeline = this,
pollStartId = event.pollStartId,
)
}
}
is TimelineEvents.EditPoll -> {
navigator.onEditPollClick(event.pollStartId)
@ -183,6 +208,12 @@ class TimelinePresenter @AssistedInject constructor(
val serverNames = calculateServerNamesForRoom(room)
navigator.onNavigateToRoom(event.roomId, serverNames)
}
is TimelineEvents.OpenThread -> {
navigator.onOpenThread(
threadRootId = event.threadRootEventId,
focusedEventId = event.focusedEvent,
)
}
}
}
@ -270,6 +301,7 @@ class TimelinePresenter @AssistedInject constructor(
}
return TimelineState(
timelineItems = timelineItems,
timelineMode = timelineMode,
timelineRoomInfo = timelineRoomInfo,
renderReadReceipts = renderReadReceipts,
newEventState = newEventState.value,
@ -277,6 +309,7 @@ class TimelinePresenter @AssistedInject constructor(
focusRequestState = focusRequestState,
messageShield = messageShield.value,
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
displayThreadSummaries = displayThreadSummaries,
eventSink = { handleEvents(it) }
)
}

View file

@ -16,6 +16,7 @@ import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import kotlinx.collections.immutable.ImmutableList
import kotlin.time.Duration
@ -24,6 +25,7 @@ import kotlin.time.Duration
data class TimelineState(
val timelineItems: ImmutableList<TimelineItem>,
val timelineRoomInfo: TimelineRoomInfo,
val timelineMode: Timeline.Mode,
val renderReadReceipts: Boolean,
val newEventState: NewEventState,
val isLive: Boolean,
@ -31,6 +33,7 @@ data class TimelineState(
// If not null, info will be rendered in a dialog
val messageShield: MessageShield?,
val resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState,
val displayThreadSummaries: Boolean,
val eventSink: (TimelineEvents) -> Unit,
) {
private val lastTimelineEvent = timelineItems.firstOrNull { it is TimelineItem.Event } as? TimelineItem.Event

View file

@ -31,6 +31,8 @@ import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
@ -45,12 +47,14 @@ import kotlin.random.Random
fun aTimelineState(
timelineItems: ImmutableList<TimelineItem> = persistentListOf(),
timelineMode: Timeline.Mode = Timeline.Mode.Live,
renderReadReceipts: Boolean = false,
timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(),
focusedEventIndex: Int = -1,
isLive: Boolean = true,
messageShield: MessageShield? = null,
resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState = aResolveVerifiedUserSendFailureState(),
displayThreadSummaries: Boolean = false,
eventSink: (TimelineEvents) -> Unit = {},
): TimelineState {
val focusedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().getOrNull(focusedEventIndex)?.eventId
@ -61,6 +65,7 @@ fun aTimelineState(
}
return TimelineState(
timelineItems = timelineItems,
timelineMode = timelineMode,
timelineRoomInfo = timelineRoomInfo,
renderReadReceipts = renderReadReceipts,
newEventState = NewEventState.None,
@ -68,6 +73,7 @@ fun aTimelineState(
focusRequestState = focusRequestState,
messageShield = messageShield,
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
displayThreadSummaries = displayThreadSummaries,
eventSink = eventSink,
)
}
@ -140,7 +146,7 @@ internal fun aTimelineItemEvent(
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
sendState: LocalEventSendState? = null,
inReplyTo: InReplyToDetails? = null,
isThreaded: Boolean = false,
threadInfo: EventThreadInfo = EventThreadInfo(threadRootId = null, threadSummary = null),
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
timelineItemReactions: TimelineItemReactions = aTimelineItemReactions(),
readReceiptState: TimelineItemReadReceipts = aTimelineItemReadReceipts(),
@ -166,7 +172,7 @@ internal fun aTimelineItemEvent(
groupPosition = groupPosition,
localSendState = sendState,
inReplyTo = inReplyTo,
isThreaded = isThreaded,
threadInfo = threadInfo,
origin = null,
timelineItemDebugInfoProvider = { debugInfo },
messageShieldProvider = { messageShield },

View file

@ -163,11 +163,13 @@ fun TimelineView(
) { timelineItem ->
TimelineItemRow(
timelineItem = timelineItem,
timelineMode = state.timelineMode,
timelineRoomInfo = state.timelineRoomInfo,
timelineProtectionState = timelineProtectionState,
renderReadReceipts = state.renderReadReceipts,
isLastOutgoingMessage = state.isLastOutgoingMessage(timelineItem.identifier()),
focusedEventId = state.focusedEventId,
displayThreadSummaries = state.displayThreadSummaries,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
onLinkLongClick = ::onLinkLongClick,

View file

@ -13,21 +13,26 @@ import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
import io.element.android.libraries.matrix.api.timeline.Timeline
// For previews
@Composable
internal fun ATimelineItemEventRow(
event: TimelineItem.Event,
timelineMode: Timeline.Mode = Timeline.Mode.Live,
timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(),
renderReadReceipts: Boolean = false,
isLastOutgoingMessage: Boolean = false,
timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(),
displayThreadSummaries: Boolean = false,
) = TimelineItemEventRow(
event = event,
timelineMode = timelineMode,
timelineRoomInfo = timelineRoomInfo,
renderReadReceipts = renderReadReceipts,
timelineProtectionState = timelineProtectionState,
isLastOutgoingMessage = isLastOutgoingMessage,
displayThreadSummaries = displayThreadSummaries,
onEventClick = {},
onLongClick = {},
onLinkClick = {},

View file

@ -36,6 +36,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.hideFromAccessibility
@ -72,6 +73,7 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI
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.architecture.AsyncData
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
@ -82,10 +84,17 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.swipe.SwipeableActionsState
import io.element.android.libraries.designsystem.swipe.rememberSwipeableActionsState
import io.element.android.libraries.designsystem.text.toPx
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.ButtonSize
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toThreadId
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.ThreadSummary
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
import io.element.android.libraries.matrix.api.timeline.item.event.getDisplayName
@ -97,6 +106,7 @@ import io.element.android.libraries.matrix.ui.messages.sender.SenderName
import io.element.android.libraries.matrix.ui.messages.sender.SenderNameMode
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonPlurals
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.time.isTalkbackActive
import io.element.android.wysiwyg.link.Link
@ -116,10 +126,12 @@ private val BUBBLE_INCOMING_OFFSET = 16.dp
@Composable
fun TimelineItemEventRow(
event: TimelineItem.Event,
timelineMode: Timeline.Mode,
timelineRoomInfo: TimelineRoomInfo,
timelineProtectionState: TimelineProtectionState,
renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
displayThreadSummaries: Boolean,
onEventClick: () -> Unit,
onLongClick: () -> Unit,
onLinkClick: (Link) -> Unit,
@ -194,6 +206,7 @@ fun TimelineItemEventRow(
}
TimelineItemEventRowContent(
event = event,
timelineMode = timelineMode,
timelineProtectionState = timelineProtectionState,
timelineRoomInfo = timelineRoomInfo,
interactionSource = interactionSource,
@ -227,6 +240,7 @@ fun TimelineItemEventRow(
} else {
TimelineItemEventRowContent(
event = event,
timelineMode = timelineMode,
timelineProtectionState = timelineProtectionState,
timelineRoomInfo = timelineRoomInfo,
interactionSource = interactionSource,
@ -241,6 +255,25 @@ fun TimelineItemEventRow(
eventContentView = eventContentView,
)
}
if (displayThreadSummaries && timelineMode !is Timeline.Mode.Thread) {
event.threadInfo.threadSummary?.let { threadSummary ->
val threadPart = stringResource(CommonStrings.common_thread)
val numberOfReplies = threadSummary.numberOfReplies.toInt().let { replies ->
pluralStringResource(CommonPlurals.common_replies, replies, replies)
}
Button(
modifier = Modifier.padding(horizontal = 24.dp, vertical = 2.dp)
.align(if (event.isMine) Alignment.End else Alignment.Start),
text = "$threadPart - $numberOfReplies",
size = ButtonSize.Small,
onClick = {
eventSink(TimelineEvents.OpenThread(event.eventId!!.toThreadId(), null))
},
)
}
}
// Read receipts / Send state
TimelineItemReadReceiptView(
state = ReadReceiptViewState(
@ -281,6 +314,7 @@ private fun SwipeSensitivity(
@Composable
private fun TimelineItemEventRowContent(
event: TimelineItem.Event,
timelineMode: Timeline.Mode,
timelineProtectionState: TimelineProtectionState,
timelineRoomInfo: TimelineRoomInfo,
interactionSource: MutableInteractionSource,
@ -360,6 +394,7 @@ private fun TimelineItemEventRowContent(
) {
MessageEventBubbleContent(
event = event,
timelineMode = timelineMode,
timelineProtectionState = timelineProtectionState,
onMessageLongClick = onLongClick,
inReplyToClick = inReplyToClick,
@ -461,6 +496,7 @@ private fun MessageSenderInformation(
@Composable
private fun MessageEventBubbleContent(
event: TimelineItem.Event,
timelineMode: Timeline.Mode,
timelineProtectionState: TimelineProtectionState,
onMessageLongClick: () -> Unit,
inReplyToClick: () -> Unit,
@ -658,7 +694,7 @@ private fun MessageEventBubbleContent(
else -> ContentPadding.Textual
}
CommonLayout(
showThreadDecoration = event.isThreaded,
showThreadDecoration = timelineMode !is Timeline.Mode.Thread && event.threadInfo.threadRootId != null,
timestampPosition = timestampPosition,
paddingBehaviour = paddingBehaviour,
inReplyToDetails = event.inReplyTo,
@ -695,3 +731,28 @@ internal fun TimelineItemEventRowPreview() = ElementPreview {
}
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemEventRowWithThreadSummaryPreview() = ElementPreview {
Column {
sequenceOf(false, true).forEach { isMine ->
ATimelineItemEventRow(
event = aTimelineItemEvent(
senderDisplayName = "Sender with a super long name that should ellipsize",
isMine = isMine,
content = aTimelineItemTextContent(
body = "A long text which will be displayed on several lines and" +
" hopefully can be manually adjusted to test different behaviors."
),
groupPosition = TimelineItemGroupPosition.First,
threadInfo = EventThreadInfo(
threadRootId = ThreadId("\$thread-root-id"),
threadSummary = ThreadSummary(AsyncData.Uninitialized, numberOfReplies = 20L)
)
),
displayThreadSummaries = true,
)
}
}
}

View file

@ -17,6 +17,8 @@ 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.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetailsProvider
@ -56,7 +58,10 @@ internal fun TimelineItemEventRowWithReplyContentToPreview(
),
inReplyTo = inReplyToDetails,
displayNameAmbiguous = displayNameAmbiguous,
isThreaded = true,
threadInfo = EventThreadInfo(
threadRootId = ThreadId("\$thread-root-id"),
threadSummary = null,
),
groupPosition = TimelineItemGroupPosition.Last,
),
)

View file

@ -31,6 +31,7 @@ import io.element.android.features.messages.impl.timeline.protection.aTimelinePr
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.utils.time.isTalkbackActive
import io.element.android.wysiwyg.link.Link
@ -38,11 +39,13 @@ import io.element.android.wysiwyg.link.Link
@Composable
fun TimelineItemGroupedEventsRow(
timelineItem: TimelineItem.GroupedEvents,
timelineMode: Timeline.Mode,
timelineRoomInfo: TimelineRoomInfo,
timelineProtectionState: TimelineProtectionState,
renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
focusedEventId: EventId?,
displayThreadSummaries: Boolean,
onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit,
@ -81,11 +84,13 @@ fun TimelineItemGroupedEventsRow(
isExpanded = isExpanded.value,
onExpandGroupClick = ::onExpandGroupClick,
timelineItem = timelineItem,
timelineMode = timelineMode,
timelineRoomInfo = timelineRoomInfo,
timelineProtectionState = timelineProtectionState,
focusedEventId = focusedEventId,
renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
displayThreadSummaries = displayThreadSummaries,
onClick = onClick,
onLongClick = onLongClick,
inReplyToClick = inReplyToClick,
@ -107,11 +112,13 @@ private fun TimelineItemGroupedEventsRowContent(
isExpanded: Boolean,
onExpandGroupClick: () -> Unit,
timelineItem: TimelineItem.GroupedEvents,
timelineMode: Timeline.Mode,
timelineRoomInfo: TimelineRoomInfo,
timelineProtectionState: TimelineProtectionState,
focusedEventId: EventId?,
renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
displayThreadSummaries: Boolean,
onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit,
@ -161,12 +168,14 @@ private fun TimelineItemGroupedEventsRowContent(
}
}.forEach { subGroupEvent ->
TimelineItemRow(
timelineMode = timelineMode,
timelineItem = subGroupEvent,
timelineRoomInfo = timelineRoomInfo,
timelineProtectionState = timelineProtectionState,
renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
focusedEventId = focusedEventId,
displayThreadSummaries = displayThreadSummaries,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
@ -206,11 +215,13 @@ internal fun TimelineItemGroupedEventsRowContentExpandedPreview() = ElementPrevi
isExpanded = true,
onExpandGroupClick = {},
timelineItem = events,
timelineMode = Timeline.Mode.Live,
timelineRoomInfo = aTimelineRoomInfo(),
timelineProtectionState = aTimelineProtectionState(),
focusedEventId = events.events.first().eventId,
renderReadReceipts = true,
isLastOutgoingMessage = false,
displayThreadSummaries = false,
onClick = {},
onLongClick = {},
onLinkLongClick = {},
@ -232,11 +243,13 @@ internal fun TimelineItemGroupedEventsRowContentCollapsePreview() = ElementPrevi
isExpanded = false,
onExpandGroupClick = {},
timelineItem = aGroupedEvents(withReadReceipts = true),
timelineMode = Timeline.Mode.Live,
timelineRoomInfo = aTimelineRoomInfo(),
timelineProtectionState = aTimelineProtectionState(),
focusedEventId = null,
renderReadReceipts = true,
isLastOutgoingMessage = false,
displayThreadSummaries = false,
onClick = {},
onLongClick = {},
onLinkLongClick = {},

View file

@ -44,6 +44,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toPx
import io.element.android.libraries.designsystem.theme.LocalBuildMeta
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.time.isTalkbackActive
@ -53,11 +54,13 @@ import kotlin.time.DurationUnit
@Composable
internal fun TimelineItemRow(
timelineItem: TimelineItem,
timelineMode: Timeline.Mode,
timelineRoomInfo: TimelineRoomInfo,
renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
timelineProtectionState: TimelineProtectionState,
focusedEventId: EventId?,
displayThreadSummaries: Boolean,
onUserDataClick: (MatrixUser) -> Unit,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
@ -161,10 +164,12 @@ internal fun TimelineItemRow(
}
),
event = timelineItem,
timelineMode = timelineMode,
timelineRoomInfo = timelineRoomInfo,
renderReadReceipts = renderReadReceipts,
timelineProtectionState = timelineProtectionState,
isLastOutgoingMessage = isLastOutgoingMessage,
displayThreadSummaries = displayThreadSummaries,
onEventClick = { onContentClick(timelineItem) },
onLongClick = { onLongClick(timelineItem) },
onLinkClick = onLinkClick,
@ -187,11 +192,13 @@ internal fun TimelineItemRow(
is TimelineItem.GroupedEvents -> {
TimelineItemGroupedEventsRow(
timelineItem = timelineItem,
timelineMode = timelineMode,
timelineRoomInfo = timelineRoomInfo,
timelineProtectionState = timelineProtectionState,
renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
focusedEventId = focusedEventId,
displayThreadSummaries = displayThreadSummaries,
onClick = onContentClick,
onLongClick = onLongClick,
inReplyToClick = inReplyToClick,

View file

@ -0,0 +1,15 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.di
import javax.inject.Qualifier
@Retention(AnnotationRetention.RUNTIME)
@MustBeDocumented
@Qualifier
annotation class LiveTimeline

View file

@ -28,6 +28,7 @@ import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import io.element.android.libraries.matrix.ui.messages.reply.map
@ -67,7 +68,6 @@ class TimelineItemEventFactory @AssistedInject constructor(
url = senderProfile.getAvatarUrl(),
size = AvatarSize.TimelineSender
)
currentTimelineItem.event
return TimelineItem.Event(
id = currentTimelineItem.uniqueId,
eventId = currentTimelineItem.eventId,
@ -86,7 +86,7 @@ class TimelineItemEventFactory @AssistedInject constructor(
readReceiptState = currentTimelineItem.computeReadReceiptState(roomMembers),
localSendState = currentTimelineItem.event.localSendState,
inReplyTo = currentTimelineItem.event.inReplyTo()?.map(permalinkParser = permalinkParser),
isThreaded = currentTimelineItem.event.isThreaded(),
threadInfo = currentTimelineItem.event.threadInfo() ?: EventThreadInfo(threadRootId = null, threadSummary = null),
origin = currentTimelineItem.event.origin,
timelineItemDebugInfoProvider = currentTimelineItem.event.timelineItemDebugInfoProvider,
messageShieldProvider = currentTimelineItem.event.messageShieldProvider,

View file

@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.core.SendHandle
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
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.LocalEventSendState
@ -81,7 +82,7 @@ sealed interface TimelineItem {
val readReceiptState: TimelineItemReadReceipts,
val localSendState: LocalEventSendState?,
val inReplyTo: InReplyToDetails?,
val isThreaded: Boolean,
val threadInfo: EventThreadInfo,
val origin: TimelineItemEventOrigin?,
val timelineItemDebugInfoProvider: TimelineItemDebugInfoProvider,
val messageShieldProvider: MessageShieldProvider,

View file

@ -19,10 +19,18 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import com.squareup.anvil.annotations.ContributesBinding
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.messages.api.MessageComposerContext
import io.element.android.libraries.architecture.Presenter
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerEvents
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerPresenter
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerState
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.permissions.api.PermissionsEvents
import io.element.android.libraries.permissions.api.PermissionsPresenter
@ -40,22 +48,29 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
import java.io.File
import javax.inject.Inject
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
class VoiceMessageComposerPresenter @Inject constructor(
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
class DefaultVoiceMessageComposerPresenter @AssistedInject constructor(
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
@Assisted private val timelineMode: Timeline.Mode,
private val voiceRecorder: VoiceRecorder,
private val analyticsService: AnalyticsService,
private val mediaSender: MediaSender,
mediaSenderFactory: MediaSender.Factory,
private val player: VoiceMessageComposerPlayer,
private val messageComposerContext: MessageComposerContext,
permissionsPresenterFactory: PermissionsPresenter.Factory
) : Presenter<VoiceMessageComposerState> {
) : VoiceMessageComposerPresenter {
@ContributesBinding(RoomScope::class)
@AssistedFactory
interface Factory : VoiceMessageComposerPresenter.Factory {
override fun create(timelineMode: Timeline.Mode): DefaultVoiceMessageComposerPresenter
}
private val permissionsPresenter = permissionsPresenterFactory.create(Manifest.permission.RECORD_AUDIO)
private val mediaSender = mediaSenderFactory.create(timelineMode)
@Composable
override fun present(): VoiceMessageComposerState {
val localCoroutineScope = rememberCoroutineScope()

View file

@ -1,27 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.voicemessages.composer
import androidx.lifecycle.Lifecycle
import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
sealed interface VoiceMessageComposerEvents {
data class RecorderEvent(
val recorderEvent: VoiceMessageRecorderEvent
) : VoiceMessageComposerEvents
data class PlayerEvent(
val playerEvent: VoiceMessagePlayerEvent,
) : VoiceMessageComposerEvents
data object SendVoiceMessage : VoiceMessageComposerEvents
data object DeleteVoiceMessage : VoiceMessageComposerEvents
data object AcceptPermissionRationale : VoiceMessageComposerEvents
data object DismissPermissionsRationale : VoiceMessageComposerEvents
data class LifecycleEvent(val event: Lifecycle.Event) : VoiceMessageComposerEvents
data object DismissSendFailureDialog : VoiceMessageComposerEvents
}

View file

@ -1,20 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.voicemessages.composer
import androidx.compose.runtime.Stable
import io.element.android.libraries.textcomposer.model.VoiceMessageState
@Stable
data class VoiceMessageComposerState(
val voiceMessageState: VoiceMessageState,
val showPermissionRationaleDialog: Boolean,
val showSendFailureDialog: Boolean,
val keepScreenOn: Boolean,
val eventSink: (VoiceMessageComposerEvents) -> Unit,
)

View file

@ -1,45 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.voicemessages.composer
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.designsystem.components.media.createFakeWaveform
import io.element.android.libraries.textcomposer.model.VoiceMessageState
import kotlinx.collections.immutable.toPersistentList
import kotlin.time.Duration.Companion.seconds
internal open class VoiceMessageComposerStateProvider : PreviewParameterProvider<VoiceMessageComposerState> {
override val values: Sequence<VoiceMessageComposerState>
get() = sequenceOf(
aVoiceMessageComposerState(voiceMessageState = VoiceMessageState.Recording(duration = 61.seconds, levels = aWaveformLevels)),
)
}
internal fun aVoiceMessageComposerState(
voiceMessageState: VoiceMessageState = VoiceMessageState.Idle,
keepScreenOn: Boolean = false,
showPermissionRationaleDialog: Boolean = false,
showSendFailureDialog: Boolean = false,
) = VoiceMessageComposerState(
voiceMessageState = voiceMessageState,
showPermissionRationaleDialog = showPermissionRationaleDialog,
showSendFailureDialog = showSendFailureDialog,
keepScreenOn = keepScreenOn,
eventSink = {},
)
internal fun aVoiceMessagePreviewState() = VoiceMessageState.Preview(
isSending = false,
isPlaying = false,
showCursor = false,
playbackProgress = 0f,
time = 10.seconds,
waveform = createFakeWaveform(),
)
internal var aWaveformLevels = List(100) { it.toFloat() / 100 }.toPersistentList()

View file

@ -10,6 +10,7 @@ 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.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.tests.testutils.lambda.lambdaError
@ -21,7 +22,8 @@ class FakeMessagesNavigator(
private val onReportContentClickLambda: (eventId: EventId, senderId: UserId) -> Unit = { _, _ -> lambdaError() },
private val onEditPollClickLambda: (eventId: EventId) -> Unit = { _ -> lambdaError() },
private val onPreviewAttachmentLambda: (attachments: ImmutableList<Attachment>) -> Unit = { _ -> lambdaError() },
private val onNavigateToRoomLambda: (roomId: RoomId, serverNames: List<String>) -> Unit = { _, _ -> lambdaError() }
private val onNavigateToRoomLambda: (roomId: RoomId, serverNames: List<String>) -> Unit = { _, _ -> lambdaError() },
private val onOpenThreadLambda: (threadRootId: ThreadId, focusedEventId: EventId?) -> Unit = { _, _ -> lambdaError() },
) : MessagesNavigator {
override fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
onShowEventDebugInfoClickLambda(eventId, debugInfo)
@ -46,4 +48,8 @@ class FakeMessagesNavigator(
override fun onNavigateToRoom(roomId: RoomId, serverNames: List<String>) {
onNavigateToRoomLambda(roomId, serverNames)
}
override fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?) {
onOpenThreadLambda(threadRootId, focusedEventId)
}
}

View file

@ -34,8 +34,8 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI
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.messages.test.timeline.voicemessages.composer.FakeDefaultVoiceMessageComposerPresenterFactory
import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
@ -56,6 +56,7 @@ import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
@ -908,7 +909,10 @@ class MessagesPresenterTest {
liveTimeline = timeline,
typingNoticeResult = { Result.success(Unit) },
)
val presenter = createMessagesPresenter(joinedRoom = room, analyticsService = analyticsService)
val presenter = createMessagesPresenter(
joinedRoom = room,
analyticsService = analyticsService,
)
presenter.testWithLifecycleOwner {
val messageEvent = aMessageEvent(
content = aTimelineItemTextContent()
@ -1047,6 +1051,7 @@ class MessagesPresenterTest {
)
val presenter = createMessagesPresenter(
joinedRoom = room,
timeline = timeline,
)
presenter.testWithLifecycleOwner {
skipItems(1)
@ -1168,6 +1173,7 @@ class MessagesPresenterTest {
liveTimeline = FakeTimeline(),
typingNoticeResult = { Result.success(Unit) },
),
timeline: Timeline = joinedRoom.liveTimeline,
navigator: FakeMessagesNavigator = FakeMessagesNavigator(),
clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(),
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
@ -1188,7 +1194,7 @@ class MessagesPresenterTest {
return MessagesPresenter(
room = joinedRoom,
composerPresenter = messageComposerPresenter,
voiceMessageComposerPresenter = { aVoiceMessageComposerState() },
voiceMessageComposerPresenterFactory = FakeDefaultVoiceMessageComposerPresenterFactory(backgroundScope),
timelinePresenter = { aTimelineState(eventSink = timelineEventSink) },
timelineProtectionPresenter = { aTimelineProtectionState() },
actionListPresenter = { anActionListState(eventSink = actionListEventSink) },
@ -1207,7 +1213,7 @@ class MessagesPresenterTest {
buildMeta = aBuildMeta(),
dispatchers = coroutineDispatchers,
htmlConverterProvider = FakeHtmlConverterProvider(),
timelineController = TimelineController(joinedRoom),
timelineController = TimelineController(joinedRoom, timeline),
permalinkParser = permalinkParser,
encryptionService = encryptionService,
analyticsService = analyticsService,

View file

@ -28,10 +28,13 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI
import io.element.android.features.poll.api.pollcontent.aPollAnswerItemList
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
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_THREAD_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
@ -192,7 +195,7 @@ class ActionListPresenterTest {
val messageEvent = aMessageEvent(
isMine = false,
isEditable = false,
isThreaded = true,
threadInfo = EventThreadInfo(threadRootId = A_THREAD_ID, threadSummary = null),
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE)
)
initialState.eventSink.invoke(
@ -426,7 +429,7 @@ class ActionListPresenterTest {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
isThreaded = true,
threadInfo = EventThreadInfo(threadRootId = A_THREAD_ID, threadSummary = null),
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE)
)
initialState.eventSink.invoke(
@ -1240,11 +1243,59 @@ class ActionListPresenterTest {
assertThat(target.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.ChangedIdentity(userDisplayName = "Alice"))
}
}
@Test
fun `present - compute for threaded timeline`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = false, timelineMode = Timeline.Mode.Thread(A_THREAD_ID))
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
isEditable = false,
content = aTimelineItemVoiceContent(
caption = null,
),
threadInfo = EventThreadInfo(A_THREAD_ID, 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,
sentTimeFull = "0 Full true",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
// This is Reply, not ReplyInThread
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.CopyLink,
TimelineItemAction.Pin,
TimelineItemAction.Redact,
)
)
)
}
}
}
private fun createActionListPresenter(
isDeveloperModeEnabled: Boolean,
room: BaseRoom = FakeBaseRoom(),
timelineMode: Timeline.Mode = Timeline.Mode.Live,
): ActionListPresenter {
val preferencesStore = InMemoryAppPreferencesStore(isDeveloperModeEnabled = isDeveloperModeEnabled)
return DefaultActionListPresenter(
@ -1253,5 +1304,6 @@ private fun createActionListPresenter(
room = room,
userSendFailureFactory = VerifiedUserSendFailureFactory(room),
dateFormatter = FakeDateFormatter(),
timelineMode = timelineMode,
)
}

View file

@ -32,6 +32,7 @@ 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
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.test.A_CAPTION
import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
@ -573,6 +574,7 @@ class AttachmentsPreviewPresenterTest {
uri = mockMediaUrl,
),
room: JoinedRoom = FakeJoinedRoom(),
timelineMode: Timeline.Mode = Timeline.Mode.Live,
permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(),
mediaPreProcessor: MediaPreProcessor = FakeMediaPreProcessor(),
temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(),
@ -595,14 +597,24 @@ class AttachmentsPreviewPresenterTest {
return AttachmentsPreviewPresenter(
attachment = aMediaAttachment(localMedia),
onDoneListener = onDoneListener,
mediaSender = MediaSender(mediaPreProcessor, room, {
MediaOptimizationConfig(compressImages = true, videoCompressionPreset = VideoCompressionPreset.STANDARD)
}),
mediaSenderFactory = object : MediaSender.Factory {
override fun create(timelineMode: Timeline.Mode): MediaSender {
return MediaSender(
preProcessor = mediaPreProcessor,
room = room,
timelineMode = timelineMode,
mediaOptimizationConfigProvider = {
MediaOptimizationConfig(compressImages = true, videoCompressionPreset = VideoCompressionPreset.STANDARD)
}
)
}
},
permalinkBuilder = permalinkBuilder,
temporaryUriDeleter = temporaryUriDeleter,
sessionCoroutineScope = this,
dispatchers = testCoroutineDispatchers(),
mediaOptimizationSelectorPresenterFactory = mediaOptimizationSelectorPresenterFactory,
timelineMode = timelineMode,
)
}

View file

@ -19,6 +19,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShieldProvider
import io.element.android.libraries.matrix.api.timeline.item.event.SendHandleProvider
@ -40,7 +41,7 @@ internal fun aMessageEvent(
canBeRepliedTo: Boolean = true,
content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, formattedBody = A_MESSAGE, isEdited = false),
inReplyTo: InReplyToDetails? = null,
isThreaded: Boolean = false,
threadInfo: EventThreadInfo = EventThreadInfo(threadRootId = null, threadSummary = null),
sendState: LocalEventSendState = LocalEventSendState.Sent(AN_EVENT_ID),
debugInfoProvider: TimelineItemDebugInfoProvider = TimelineItemDebugInfoProvider { aTimelineItemDebugInfo() },
messageShieldProvider: MessageShieldProvider = MessageShieldProvider { null },
@ -61,7 +62,7 @@ internal fun aMessageEvent(
readReceiptState = TimelineItemReadReceipts(emptyList<ReadReceiptData>().toImmutableList()),
localSendState = sendState,
inReplyTo = inReplyTo,
isThreaded = isThreaded,
threadInfo = threadInfo,
origin = null,
timelineItemDebugInfoProvider = debugInfoProvider,
messageShieldProvider = messageShieldProvider,

View file

@ -45,6 +45,7 @@ import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.TimelineException
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
@ -1521,6 +1522,7 @@ class MessageComposerPresenterTest {
room: JoinedRoom = FakeJoinedRoom(
typingNoticeResult = { Result.success(Unit) }
),
timeline: Timeline = room.liveTimeline,
navigator: MessagesNavigator = FakeMessagesNavigator(),
pickerProvider: PickerProvider = this@MessageComposerPresenterTest.pickerProvider,
locationService: LocationService = FakeLocationService(true),
@ -1546,11 +1548,21 @@ class MessageComposerPresenterTest {
mediaPickerProvider = pickerProvider,
sessionPreferencesStore = sessionPreferencesStore,
localMediaFactory = localMediaFactory,
mediaSender = MediaSender(
preProcessor = mediaPreProcessor,
room = room,
mediaOptimizationConfigProvider = { MediaOptimizationConfig(compressImages = true, videoCompressionPreset = VideoCompressionPreset.STANDARD) }
),
mediaSenderFactory = object : MediaSender.Factory {
override fun create(timelineMode: Timeline.Mode): MediaSender {
return MediaSender(
preProcessor = mediaPreProcessor,
room = room,
timelineMode = timelineMode,
mediaOptimizationConfigProvider = {
MediaOptimizationConfig(
compressImages = true,
videoCompressionPreset = VideoCompressionPreset.STANDARD
)
}
)
}
},
snackbarDispatcher = snackbarDispatcher,
analyticsService = analyticsService,
locationService = locationService,
@ -1560,7 +1572,7 @@ class MessageComposerPresenterTest {
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter),
permalinkParser = permalinkParser,
permalinkBuilder = permalinkBuilder,
timelineController = TimelineController(room),
timelineController = TimelineController(room, timeline),
draftService = draftService,
mentionSpanProvider = mentionSpanProvider,
pillificationHelper = textPillificationHelper,

View file

@ -17,6 +17,7 @@ import io.element.android.features.messages.impl.pinned.PinnedEventsTimelineProv
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.sync.SyncService
@ -297,6 +298,7 @@ class PinnedMessagesListPresenterTest {
room: JoinedRoom = FakeJoinedRoom(),
syncService: SyncService = FakeSyncService(),
analyticsService: AnalyticsService = FakeAnalyticsService(),
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
): PinnedMessagesListPresenter {
val timelineProvider = PinnedEventsTimelineProvider(
room = room,
@ -314,6 +316,7 @@ class PinnedMessagesListPresenterTest {
actionListPresenter = { anActionListState() },
linkPresenter = { aLinkState() },
analyticsService = analyticsService,
featureFlagService = featureFlagService,
sessionCoroutineScope = this,
)
}

View file

@ -33,7 +33,7 @@ class TimelineControllerTest {
liveTimeline = liveTimeline,
createTimelineResult = { Result.success(detachedTimeline) }
)
val sut = TimelineController(joinedRoom)
val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline)
sut.activeTimelineFlow().test {
awaitItem().also { state ->
@ -72,7 +72,7 @@ class TimelineControllerTest {
}
}
)
val sut = TimelineController(joinedRoom)
val sut = TimelineController(joinedRoom, liveTimeline)
sut.activeTimelineFlow().test {
awaitItem().also { state ->
@ -100,7 +100,7 @@ class TimelineControllerTest {
val joinedRoom = FakeJoinedRoom(
liveTimeline = liveTimeline
)
val sut = TimelineController(joinedRoom)
val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline)
sut.activeTimelineFlow().test {
awaitItem().also { state ->
assertThat(state).isEqualTo(liveTimeline)
@ -119,7 +119,7 @@ class TimelineControllerTest {
liveTimeline = liveTimeline,
createTimelineResult = { Result.success(detachedTimeline) }
)
val sut = TimelineController(joinedRoom)
val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline)
sut.activeTimelineFlow().test {
awaitItem().also { state ->
assertThat(state).isEqualTo(liveTimeline)
@ -147,7 +147,7 @@ class TimelineControllerTest {
val joinedRoom = FakeJoinedRoom(
liveTimeline = liveTimeline
)
val sut = TimelineController(joinedRoom)
val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline)
assertThat(sut.timelineItems().first()).hasSize(1)
}
@ -169,7 +169,7 @@ class TimelineControllerTest {
liveTimeline = liveTimeline,
createTimelineResult = { Result.success(detachedTimeline) }
)
val sut = TimelineController(joinedRoom)
val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline)
sut.activeTimelineFlow().test {
sut.focusOnEvent(AN_EVENT_ID)
awaitItem().also { state ->
@ -194,7 +194,7 @@ class TimelineControllerTest {
liveTimeline = liveTimeline,
createTimelineResult = { Result.success(detachedTimeline) }
)
val sut = TimelineController(joinedRoom)
val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline)
sut.activeTimelineFlow().test {
awaitItem().also { state ->

View file

@ -28,6 +28,7 @@ import io.element.android.features.poll.api.actions.SendPollResponseAction
import io.element.android.features.poll.test.actions.FakeEndPollAction
import io.element.android.features.poll.test.actions.FakeSendPollResponseAction
import io.element.android.features.roomcall.api.aStandByCallState
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.RoomId
import io.element.android.libraries.matrix.api.core.UniqueId
@ -787,6 +788,7 @@ class TimelinePresenterTest {
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(),
markAsFullyRead: MarkAsFullyRead = FakeMarkAsFullyRead(),
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
): TimelinePresenter {
return TimelinePresenter(
timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(),
@ -799,11 +801,12 @@ class TimelinePresenterTest {
sendPollResponseAction = sendPollResponseAction,
sessionPreferencesStore = sessionPreferencesStore,
timelineItemIndexer = timelineItemIndexer,
timelineController = TimelineController(room),
timelineController = TimelineController(room, timeline),
resolveVerifiedUserSendFailurePresenter = { aResolveVerifiedUserSendFailureState() },
typingNotificationPresenter = { aTypingNotificationState() },
roomCallStatePresenter = { aStandByCallState() },
markAsFullyRead = markAsFullyRead,
featureFlagService = featureFlagService,
)
}
}

View file

@ -40,6 +40,7 @@ import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.media.ThumbnailInfo
import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
@ -749,14 +750,14 @@ class TimelineItemContentMessageFactoryTest {
body: String = "Body",
inReplyTo: InReplyTo? = null,
isEdited: Boolean = false,
isThreaded: Boolean = false,
threadInfo: EventThreadInfo = EventThreadInfo(threadRootId = null, threadSummary = null),
type: MessageType,
): MessageContent {
return MessageContent(
body = body,
inReplyTo = inReplyTo,
isEdited = isEdited,
isThreaded = isThreaded,
threadInfo = threadInfo,
type = type,
)
}

View file

@ -18,6 +18,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
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_USER_ID
@ -41,7 +42,7 @@ class TimelineItemGrouperTest {
isEditable = false,
canBeRepliedTo = false,
inReplyTo = null,
isThreaded = false,
threadInfo = EventThreadInfo(threadRootId = null, threadSummary = null),
origin = null,
timelineItemDebugInfoProvider = { aTimelineItemDebugInfo() },
messageShieldProvider = { null },

View file

@ -17,10 +17,13 @@ import app.cash.turbine.TurbineTestContext
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerEvents
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerState
import io.element.android.features.messages.impl.messagecomposer.aReplyMode
import io.element.android.features.messages.test.FakeMessageComposerContext
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.AudioInfo
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
@ -75,6 +78,7 @@ class VoiceMessageComposerPresenterTest {
private val mediaSender = MediaSender(
preProcessor = mediaPreProcessor,
room = joinedRoom,
timelineMode = Timeline.Mode.Live,
mediaOptimizationConfigProvider = { MediaOptimizationConfig(compressImages = true, videoCompressionPreset = VideoCompressionPreset.STANDARD) },
)
private val messageComposerContext = FakeMessageComposerContext()
@ -86,7 +90,7 @@ class VoiceMessageComposerPresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = createVoiceMessageComposerPresenter()
val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@ -100,7 +104,7 @@ class VoiceMessageComposerPresenterTest {
@Test
fun `present - recording state`() = runTest {
val presenter = createVoiceMessageComposerPresenter()
val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@ -116,7 +120,7 @@ class VoiceMessageComposerPresenterTest {
@Test
fun `present - recording keeps screen on`() = runTest {
val presenter = createVoiceMessageComposerPresenter()
val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@ -140,7 +144,7 @@ class VoiceMessageComposerPresenterTest {
@Test
fun `present - abort recording`() = runTest {
val presenter = createVoiceMessageComposerPresenter()
val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@ -155,7 +159,7 @@ class VoiceMessageComposerPresenterTest {
@Test
fun `present - finish recording`() = runTest {
val presenter = createVoiceMessageComposerPresenter()
val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@ -172,7 +176,7 @@ class VoiceMessageComposerPresenterTest {
@Test
fun `present - play recording before it is ready`() = runTest {
val presenter = createVoiceMessageComposerPresenter()
val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@ -191,7 +195,7 @@ class VoiceMessageComposerPresenterTest {
@Test
fun `present - play recording`() = runTest {
val presenter = createVoiceMessageComposerPresenter()
val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@ -209,7 +213,7 @@ class VoiceMessageComposerPresenterTest {
@Test
fun `present - pause recording`() = runTest {
val presenter = createVoiceMessageComposerPresenter()
val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@ -228,7 +232,7 @@ class VoiceMessageComposerPresenterTest {
@Test
fun `present - seek recording`() = runTest {
val presenter = createVoiceMessageComposerPresenter()
val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@ -255,7 +259,7 @@ class VoiceMessageComposerPresenterTest {
@Test
fun `present - delete recording`() = runTest {
val presenter = createVoiceMessageComposerPresenter()
val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@ -273,7 +277,7 @@ class VoiceMessageComposerPresenterTest {
@Test
fun `present - delete while playing`() = runTest {
val presenter = createVoiceMessageComposerPresenter()
val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@ -295,7 +299,7 @@ class VoiceMessageComposerPresenterTest {
@Test
fun `present - send recording`() = runTest {
val presenter = createVoiceMessageComposerPresenter()
val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@ -314,7 +318,7 @@ class VoiceMessageComposerPresenterTest {
@Test
fun `present - sending is tracked`() = runTest {
val presenter = createVoiceMessageComposerPresenter()
val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@ -343,7 +347,7 @@ class VoiceMessageComposerPresenterTest {
@Test
fun `present - send while playing`() = runTest {
val presenter = createVoiceMessageComposerPresenter()
val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@ -365,7 +369,7 @@ class VoiceMessageComposerPresenterTest {
@Test
fun `present - send recording before previous completed, waits`() = runTest {
val presenter = createVoiceMessageComposerPresenter()
val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@ -390,7 +394,7 @@ class VoiceMessageComposerPresenterTest {
fun `present - send failures aren't tracked`() = runTest {
// Let sending fail due to media preprocessing error
mediaPreProcessor.givenResult(Result.failure(Exception()))
val presenter = createVoiceMessageComposerPresenter()
val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@ -414,7 +418,7 @@ class VoiceMessageComposerPresenterTest {
@Test
fun `present - send failures can be retried`() = runTest {
// Let sending fail due to media preprocessing error
val presenter = createVoiceMessageComposerPresenter()
val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@ -443,7 +447,7 @@ class VoiceMessageComposerPresenterTest {
@Test
fun `present - send failures are displayed as an error dialog`() = runTest {
val presenter = createVoiceMessageComposerPresenter()
val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@ -478,7 +482,7 @@ class VoiceMessageComposerPresenterTest {
@Test
fun `present - send error - missing recording is tracked`() = runTest {
val presenter = createVoiceMessageComposerPresenter()
val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@ -499,7 +503,7 @@ class VoiceMessageComposerPresenterTest {
fun `present - record error - security exceptions are tracked`() = runTest {
val exception = SecurityException("")
voiceRecorder.givenThrowsSecurityException(exception)
val presenter = createVoiceMessageComposerPresenter()
val presenter = createDefaultVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@ -521,7 +525,7 @@ class VoiceMessageComposerPresenterTest {
val permissionsPresenter = createFakePermissionsPresenter(
recordPermissionGranted = false,
)
val presenter = createVoiceMessageComposerPresenter(
val presenter = createDefaultVoiceMessageComposerPresenter(
permissionsPresenter = permissionsPresenter,
)
moleculeFlow(RecompositionMode.Immediate) {
@ -550,7 +554,7 @@ class VoiceMessageComposerPresenterTest {
val permissionsPresenter = createFakePermissionsPresenter(
recordPermissionGranted = false,
)
val presenter = createVoiceMessageComposerPresenter(
val presenter = createDefaultVoiceMessageComposerPresenter(
permissionsPresenter = permissionsPresenter,
)
moleculeFlow(RecompositionMode.Immediate) {
@ -584,7 +588,7 @@ class VoiceMessageComposerPresenterTest {
val permissionsPresenter = createFakePermissionsPresenter(
recordPermissionGranted = false,
)
val presenter = createVoiceMessageComposerPresenter(
val presenter = createDefaultVoiceMessageComposerPresenter(
permissionsPresenter = permissionsPresenter,
)
moleculeFlow(RecompositionMode.Immediate) {
@ -656,17 +660,22 @@ class VoiceMessageComposerPresenterTest {
}
}
private fun TestScope.createVoiceMessageComposerPresenter(
private fun TestScope.createDefaultVoiceMessageComposerPresenter(
permissionsPresenter: PermissionsPresenter = createFakePermissionsPresenter(),
): VoiceMessageComposerPresenter {
return VoiceMessageComposerPresenter(
backgroundScope,
voiceRecorder,
analyticsService,
mediaSender,
): DefaultVoiceMessageComposerPresenter {
return DefaultVoiceMessageComposerPresenter(
sessionCoroutineScope = backgroundScope,
timelineMode = Timeline.Mode.Live,
voiceRecorder = voiceRecorder,
analyticsService = analyticsService,
mediaSenderFactory = object : MediaSender.Factory {
override fun create(timelineMode: Timeline.Mode): MediaSender {
return mediaSender
}
},
player = VoiceMessageComposerPlayer(FakeMediaPlayer(), this),
messageComposerContext = messageComposerContext,
FakePermissionsPresenterFactory(permissionsPresenter),
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter),
)
}