Merge pull request #3392 from element-hq/feature/fga/pinned_messages_list

[Feature] Pinned messages list
This commit is contained in:
ganfra
2024-09-06 16:32:44 +02:00
committed by GitHub
98 changed files with 2279 additions and 357 deletions

View File

@@ -85,6 +85,7 @@ import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import timber.log.Timber
import java.util.Optional
import java.util.UUID
@ContributesNode(SessionScope::class)
class LoggedInFlowNode @AssistedInject constructor(
@@ -203,7 +204,8 @@ class LoggedInFlowNode @AssistedInject constructor(
val serverNames: List<String> = emptyList(),
val trigger: JoinedRoom.Trigger? = null,
val roomDescription: RoomDescription? = null,
val initialElement: RoomNavigationTarget = RoomNavigationTarget.Messages()
val initialElement: RoomNavigationTarget = RoomNavigationTarget.Messages(),
val targetId: UUID = UUID.randomUUID(),
) : NavTarget
@Parcelize
@@ -294,21 +296,24 @@ class LoggedInFlowNode @AssistedInject constructor(
coroutineScope.launch { attachRoom(roomId.toRoomIdOrAlias()) }
}
override fun onPermalinkClick(data: PermalinkData) {
override fun onPermalinkClick(data: PermalinkData, pushToBackstack: Boolean) {
when (data) {
is PermalinkData.UserLink -> {
// Should not happen (handled by MessagesNode)
Timber.e("User link clicked: ${data.userId}.")
}
is PermalinkData.RoomLink -> {
backstack.push(
NavTarget.Room(
val target = NavTarget.Room(
roomIdOrAlias = data.roomIdOrAlias,
serverNames = data.viaParameters,
trigger = JoinedRoom.Trigger.Timeline,
initialElement = RoomNavigationTarget.Messages(data.eventId),
)
)
if (pushToBackstack) {
backstack.push(target)
} else {
backstack.replace(target)
}
}
is PermalinkData.FallbackLink,
is PermalinkData.RoomEmailInviteLink -> {

View File

@@ -77,7 +77,7 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
), DaggerComponentOwner {
interface Callback : Plugin {
fun onOpenRoom(roomId: RoomId)
fun onPermalinkClick(data: PermalinkData)
fun onPermalinkClick(data: PermalinkData, pushToBackstack: Boolean)
fun onForwardedToSingleRoom(roomId: RoomId)
fun onOpenGlobalNotificationSettings()
}
@@ -128,6 +128,14 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
override fun onOpenRoom(roomId: RoomId) {
callbacks.forEach { it.onOpenRoom(roomId) }
}
override fun onPermalinkClick(data: PermalinkData, pushToBackstack: Boolean) {
callbacks.forEach { it.onPermalinkClick(data, pushToBackstack) }
}
override fun onForwardedToSingleRoom(roomId: RoomId) {
callbacks.forEach { it.onForwardedToSingleRoom(roomId) }
}
}
return roomDetailsEntryPoint.nodeBuilder(this, buildContext)
.params(RoomDetailsEntryPoint.Params(initialTarget))
@@ -138,27 +146,7 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
is NavTarget.Messages -> {
val callback = object : MessagesEntryPoint.Callback {
override fun onRoomDetailsClick() {
backstack.push(NavTarget.RoomDetails)
}
override fun onUserDataClick(userId: UserId) {
backstack.push(NavTarget.RoomMemberDetails(userId))
}
override fun onPermalinkClick(data: PermalinkData) {
callbacks.forEach { it.onPermalinkClick(data) }
}
override fun onForwardedToSingleRoom(roomId: RoomId) {
callbacks.forEach { it.onForwardedToSingleRoom(roomId) }
}
}
messagesEntryPoint.nodeBuilder(this, buildContext)
.params(MessagesEntryPoint.Params(navTarget.focusedEventId))
.callback(callback)
.build()
createMessagesNode(buildContext, navTarget)
}
NavTarget.RoomDetails -> {
createRoomDetailsNode(buildContext, RoomDetailsEntryPoint.InitialTarget.RoomDetails)
@@ -172,6 +160,36 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
}
}
private fun createMessagesNode(
buildContext: BuildContext,
navTarget: NavTarget.Messages,
): Node {
val callback = object : MessagesEntryPoint.Callback {
override fun onRoomDetailsClick() {
backstack.push(NavTarget.RoomDetails)
}
override fun onUserDataClick(userId: UserId) {
backstack.push(NavTarget.RoomMemberDetails(userId))
}
override fun onPermalinkClick(data: PermalinkData, pushToBackstack: Boolean) {
callbacks.forEach { it.onPermalinkClick(data, pushToBackstack) }
}
override fun onForwardedToSingleRoom(roomId: RoomId) {
callbacks.forEach { it.onForwardedToSingleRoom(roomId) }
}
}
val params = MessagesEntryPoint.Params(
MessagesEntryPoint.InitialTarget.Messages(navTarget.focusedEventId)
)
return messagesEntryPoint.nodeBuilder(this, buildContext)
.params(params)
.callback(callback)
.build()
}
sealed interface NavTarget : Parcelable {
@Parcelize
data class Messages(val focusedEventId: EventId? = null) : NavTarget

View File

@@ -16,6 +16,7 @@
plugins {
id("io.element.android-compose-library")
id("kotlin-parcelize")
}
android {

View File

@@ -16,17 +16,26 @@
package io.element.android.features.messages.api
import android.os.Parcelable
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.architecture.NodeInputs
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.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import kotlinx.parcelize.Parcelize
interface MessagesEntryPoint : FeatureEntryPoint {
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
sealed interface InitialTarget : Parcelable {
@Parcelize
data class Messages(val focusedEventId: EventId?) : InitialTarget
@Parcelize
data object PinnedMessages : InitialTarget
}
interface NodeBuilder {
fun params(params: Params): NodeBuilder
@@ -34,14 +43,14 @@ interface MessagesEntryPoint : FeatureEntryPoint {
fun build(): Node
}
data class Params(
val focusedEventId: EventId?,
)
interface Callback : Plugin {
fun onRoomDetailsClick()
fun onUserDataClick(userId: UserId)
fun onPermalinkClick(data: PermalinkData)
fun onPermalinkClick(data: PermalinkData, pushToBackstack: Boolean)
fun onForwardedToSingleRoom(roomId: RoomId)
}
data class Params(val initialTarget: InitialTarget) : NodeInputs
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
}

View File

@@ -0,0 +1,24 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.api.pinned
import androidx.compose.runtime.Composable
fun interface IsPinnedMessagesFeatureEnabled {
@Composable
operator fun invoke(): Boolean
}

View File

@@ -32,7 +32,7 @@ class DefaultMessagesEntryPoint @Inject constructor() : MessagesEntryPoint {
return object : MessagesEntryPoint.NodeBuilder {
override fun params(params: MessagesEntryPoint.Params): MessagesEntryPoint.NodeBuilder {
plugins += MessagesFlowNode.Inputs(focusedEventId = params.focusedEventId)
plugins += MessagesEntryPoint.Params(params.initialTarget)
return this
}
@@ -47,3 +47,8 @@ class DefaultMessagesEntryPoint @Inject constructor() : MessagesEntryPoint {
}
}
}
internal fun MessagesEntryPoint.InitialTarget.toNavTarget() = when (this) {
is MessagesEntryPoint.InitialTarget.Messages -> MessagesFlowNode.NavTarget.Messages(focusedEventId)
MessagesEntryPoint.InitialTarget.PinnedMessages -> MessagesFlowNode.NavTarget.PinnedMessagesList
}

View File

@@ -21,6 +21,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
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.node.node
@@ -41,7 +42,10 @@ import io.element.android.features.messages.api.MessagesEntryPoint
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode
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.timeline.TimelineController
import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNode
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
@@ -54,7 +58,6 @@ import io.element.android.features.poll.api.create.CreatePollEntryPoint
import io.element.android.features.poll.api.create.CreatePollMode
import io.element.android.libraries.architecture.BackstackWithOverlayBox
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.architecture.overlay.Overlay
@@ -64,9 +67,11 @@ 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.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.alias.matches
import io.element.android.libraries.matrix.api.room.joinedRoomMembers
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.ui.messages.LocalRoomMemberProfilesCache
@@ -81,7 +86,6 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.parcelize.Parcelize
import timber.log.Timber
@ContributesNode(RoomScope::class)
class MessagesFlowNode @AssistedInject constructor(
@@ -96,9 +100,11 @@ class MessagesFlowNode @AssistedInject constructor(
private val room: MatrixRoom,
private val roomMemberProfilesCache: RoomMemberProfilesCache,
private val mentionSpanTheme: MentionSpanTheme,
private val pinnedEventsTimelineProvider: PinnedEventsTimelineProvider,
private val timelineController: TimelineController,
) : BaseFlowNode<MessagesFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Messages,
initialElement = plugins.filterIsInstance<MessagesEntryPoint.Params>().first().initialTarget.toNavTarget(),
savedStateMap = buildContext.savedStateMap,
),
overlay = Overlay(
@@ -107,16 +113,12 @@ class MessagesFlowNode @AssistedInject constructor(
buildContext = buildContext,
plugins = plugins
) {
data class Inputs(val focusedEventId: EventId?) : NodeInputs
private val inputs = inputs<Inputs>()
sealed interface NavTarget : Parcelable {
@Parcelize
data object Empty : NavTarget
@Parcelize
data object Messages : NavTarget
data class Messages(val focusedEventId: EventId?) : NavTarget
@Parcelize
data class MediaViewer(
@@ -135,7 +137,7 @@ class MessagesFlowNode @AssistedInject constructor(
data class EventDebugInfo(val eventId: EventId?, val debugInfo: TimelineItemDebugInfo) : NavTarget
@Parcelize
data class ForwardEvent(val eventId: EventId) : NavTarget
data class ForwardEvent(val eventId: EventId, val fromPinnedEvents: Boolean) : NavTarget
@Parcelize
data class ReportMessage(val eventId: EventId, val senderId: UserId) : NavTarget
@@ -148,18 +150,27 @@ class MessagesFlowNode @AssistedInject constructor(
@Parcelize
data class EditPoll(val eventId: EventId) : NavTarget
@Parcelize
data object PinnedMessagesList : NavTarget
}
private val callbacks = plugins<MessagesEntryPoint.Callback>()
override fun onBuilt() {
super.onBuilt()
lifecycle.subscribe(
onDestroy = {
timelineController.close()
}
)
room.membersStateFlow
.onEach { membersState ->
roomMemberProfilesCache.replace(membersState.joinedRoomMembers())
}
.launchIn(lifecycleScope)
pinnedEventsTimelineProvider.launchIn(lifecycleScope)
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@@ -183,7 +194,7 @@ class MessagesFlowNode @AssistedInject constructor(
}
override fun onPermalinkClick(data: PermalinkData) {
callbacks.forEach { it.onPermalinkClick(data) }
callbacks.forEach { it.onPermalinkClick(data, pushToBackstack = true) }
}
override fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
@@ -191,7 +202,7 @@ class MessagesFlowNode @AssistedInject constructor(
}
override fun onForwardEventClick(eventId: EventId) {
backstack.push(NavTarget.ForwardEvent(eventId))
backstack.push(NavTarget.ForwardEvent(eventId, fromPinnedEvents = false))
}
override fun onReportMessage(eventId: EventId, senderId: UserId) {
@@ -220,12 +231,10 @@ class MessagesFlowNode @AssistedInject constructor(
}
override fun onViewAllPinnedEvents() {
Timber.d("On View All Pinned Events not implemented yet.")
backstack.push(NavTarget.PinnedMessagesList)
}
}
val inputs = MessagesNode.Inputs(
focusedEventId = inputs.focusedEventId,
)
val inputs = MessagesNode.Inputs(focusedEventId = navTarget.focusedEventId)
createNode<MessagesNode>(buildContext, listOf(callback, inputs))
}
is NavTarget.MediaViewer -> {
@@ -251,7 +260,12 @@ class MessagesFlowNode @AssistedInject constructor(
createNode<EventDebugInfoNode>(buildContext, listOf(inputs))
}
is NavTarget.ForwardEvent -> {
val inputs = ForwardMessagesNode.Inputs(navTarget.eventId)
val timelineProvider = if (navTarget.fromPinnedEvents) {
pinnedEventsTimelineProvider
} else {
timelineController
}
val inputs = ForwardMessagesNode.Inputs(navTarget.eventId, timelineProvider)
val callback = object : ForwardMessagesNode.Callback {
override fun onForwardedToSingleRoom(roomId: RoomId) {
callbacks.forEach { it.onForwardedToSingleRoom(roomId) }
@@ -276,6 +290,38 @@ class MessagesFlowNode @AssistedInject constructor(
.params(CreatePollEntryPoint.Params(mode = CreatePollMode.EditPoll(eventId = navTarget.eventId)))
.build()
}
NavTarget.PinnedMessagesList -> {
val callback = object : PinnedMessagesListNode.Callback {
override fun onEventClick(event: TimelineItem.Event) {
processEventClick(event)
}
override fun onUserDataClick(userId: UserId) {
callbacks.forEach { it.onUserDataClick(userId) }
}
override fun onViewInTimelineClick(eventId: EventId) {
val permalinkData = PermalinkData.RoomLink(
roomIdOrAlias = room.roomId.toRoomIdOrAlias(),
eventId = eventId,
)
callbacks.forEach { it.onPermalinkClick(permalinkData, pushToBackstack = false) }
}
override fun onRoomPermalinkClick(data: PermalinkData.RoomLink) {
callbacks.forEach { it.onPermalinkClick(data, pushToBackstack = !room.matches(data.roomIdOrAlias)) }
}
override fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo))
}
override fun onForwardEventClick(eventId: EventId) {
backstack.push(NavTarget.ForwardEvent(eventId = eventId, fromPinnedEvents = true))
}
}
createNode<PinnedMessagesListNode>(buildContext, plugins = listOf(callback))
}
NavTarget.Empty -> {
node(buildContext) {}
}

View File

@@ -37,7 +37,6 @@ import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
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.timeline.TimelineController
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
@@ -75,7 +74,6 @@ class MessagesNode @AssistedInject constructor(
private val permalinkParser: PermalinkParser,
@ApplicationContext
private val context: Context,
private val timelineController: TimelineController,
) : Node(buildContext, plugins = plugins), MessagesNavigator {
private val presenter = presenterFactory.create(this)
private val callbacks = plugins<Callback>()
@@ -107,7 +105,6 @@ class MessagesNode @AssistedInject constructor(
analyticsService.capture(room.toAnalyticsViewRoom())
},
onDestroy = {
timelineController.close()
mediaPlayer.close()
}
)

View File

@@ -38,6 +38,7 @@ import io.element.android.features.messages.api.timeline.HtmlConverterProvider
import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionPostProcessor
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.messagecomposer.MessageComposerState
@@ -98,7 +99,7 @@ class MessagesPresenter @AssistedInject constructor(
private val voiceMessageComposerPresenter: VoiceMessageComposerPresenter,
timelinePresenterFactory: TimelinePresenter.Factory,
private val typingNotificationPresenter: TypingNotificationPresenter,
private val actionListPresenter: ActionListPresenter,
private val actionListPresenterFactory: ActionListPresenter.Factory,
private val customReactionPresenter: CustomReactionPresenter,
private val reactionSummaryPresenter: ReactionSummaryPresenter,
private val readReceiptBottomSheetPresenter: ReadReceiptBottomSheetPresenter,
@@ -114,6 +115,7 @@ class MessagesPresenter @AssistedInject constructor(
private val permalinkParser: PermalinkParser,
) : Presenter<MessagesState> {
private val timelinePresenter = timelinePresenterFactory.create(navigator = navigator)
private val actionListPresenter = actionListPresenterFactory.create(TimelineItemActionPostProcessor.Default)
@AssistedFactory
interface Factory {
@@ -286,6 +288,7 @@ class MessagesPresenter @AssistedInject constructor(
TimelineItemAction.EndPoll -> handleEndPollAction(targetEvent, timelineState)
TimelineItemAction.Pin -> handlePinAction(targetEvent)
TimelineItemAction.Unpin -> handleUnpinAction(targetEvent)
TimelineItemAction.ViewInTimeline -> Unit
}
}

View File

@@ -23,8 +23,14 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import com.squareup.anvil.annotations.ContributesBinding
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.messages.api.pinned.IsPinnedMessagesFeatureEnabled
import io.element.android.features.messages.impl.UserEventPermissions
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionPostProcessor
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemCallNotifyContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
@@ -36,8 +42,7 @@ import io.element.android.features.messages.impl.timeline.model.event.canBeCopie
import io.element.android.features.messages.impl.timeline.model.event.canBeForwarded
import io.element.android.features.messages.impl.timeline.model.event.canReact
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
@@ -47,13 +52,26 @@ import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import javax.inject.Inject
class ActionListPresenter @Inject constructor(
interface ActionListPresenter : Presenter<ActionListState> {
interface Factory {
fun create(postProcessor: TimelineItemActionPostProcessor): ActionListPresenter
}
}
class DefaultActionListPresenter @AssistedInject constructor(
@Assisted
private val postProcessor: TimelineItemActionPostProcessor,
private val appPreferencesStore: AppPreferencesStore,
private val featureFlagsService: FeatureFlagService,
private val isPinnedMessagesFeatureEnabled: IsPinnedMessagesFeatureEnabled,
private val room: MatrixRoom,
) : Presenter<ActionListState> {
) : ActionListPresenter {
@AssistedFactory
@ContributesBinding(RoomScope::class)
interface Factory : ActionListPresenter.Factory {
override fun create(postProcessor: TimelineItemActionPostProcessor): DefaultActionListPresenter
}
@Composable
override fun present(): ActionListState {
val localCoroutineScope = rememberCoroutineScope()
@@ -63,7 +81,7 @@ class ActionListPresenter @Inject constructor(
}
val isDeveloperModeEnabled by appPreferencesStore.isDeveloperModeEnabledFlow().collectAsState(initial = false)
val isPinnedEventsEnabled by featureFlagsService.isFeatureEnabledFlow(FeatureFlags.PinnedEvents).collectAsState(initial = false)
val isPinnedEventsEnabled = isPinnedMessagesFeatureEnabled()
val pinnedEventIds by remember {
room.roomInfoFlow.map { it.pinnedEventIds }
}.collectAsState(initial = persistentListOf())
@@ -105,6 +123,7 @@ class ActionListPresenter @Inject constructor(
isPinnedEventsEnabled = isPinnedEventsEnabled,
isEventPinned = pinnedEventIds.contains(timelineItem.eventId),
)
val displayEmojiReactions = usersEventPermissions.canSendReaction &&
timelineItem.content.canReact()
if (actions.isNotEmpty() || displayEmojiReactions) {
@@ -117,7 +136,6 @@ class ActionListPresenter @Inject constructor(
target.value = ActionListState.Target.None
}
}
}
private fun buildActions(
timelineItem: TimelineItem.Event,
@@ -167,7 +185,10 @@ private fun buildActions(
if (canRedact) {
add(TimelineItemAction.Redact)
}
}.postFilter(timelineItem.content)
}
.postFilter(timelineItem.content)
.let(postProcessor::process)
}
}
/**

View File

@@ -29,6 +29,7 @@ sealed class TimelineItemAction(
@DrawableRes val icon: Int,
val destructive: Boolean = false
) {
data object ViewInTimeline : TimelineItemAction(CommonStrings.action_view_in_timeline, CompoundDrawables.ic_compound_visibility_on)
data object Forward : TimelineItemAction(CommonStrings.action_forward, CompoundDrawables.ic_compound_forward)
data object Copy : TimelineItemAction(CommonStrings.action_copy, CompoundDrawables.ic_compound_copy)
data object CopyLink : TimelineItemAction(CommonStrings.action_copy_link_to_message, CompoundDrawables.ic_compound_link)

View File

@@ -0,0 +1,27 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.actionlist.model
fun interface TimelineItemActionPostProcessor {
fun process(actions: List<TimelineItemAction>): List<TimelineItemAction>
object Default : TimelineItemActionPostProcessor {
override fun process(actions: List<TimelineItemAction>): List<TimelineItemAction> {
return actions
}
}
}

View File

@@ -34,6 +34,7 @@ import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.RoomScope
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.timeline.TimelineProvider
import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint
import io.element.android.libraries.roomselect.api.RoomSelectMode
import kotlinx.parcelize.Parcelize
@@ -59,10 +60,13 @@ class ForwardMessagesNode @AssistedInject constructor(
fun onForwardedToSingleRoom(roomId: RoomId)
}
data class Inputs(val eventId: EventId) : NodeInputs
data class Inputs(
val eventId: EventId,
val timelineProvider: TimelineProvider,
) : NodeInputs
private val inputs = inputs<Inputs>()
private val presenter = presenterFactory.create(inputs.eventId.value)
private val presenter = presenterFactory.create(inputs.eventId.value, inputs.timelineProvider)
private val callbacks = plugins.filterIsInstance<Callback>()
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {

View File

@@ -36,14 +36,14 @@ import kotlinx.coroutines.launch
class ForwardMessagesPresenter @AssistedInject constructor(
@Assisted eventId: String,
@Assisted private val timelineProvider: TimelineProvider,
private val appCoroutineScope: CoroutineScope,
private val timelineProvider: TimelineProvider,
) : Presenter<ForwardMessagesState> {
private val eventId: EventId = EventId(eventId)
@AssistedFactory
interface Factory {
fun create(eventId: String): ForwardMessagesPresenter
fun create(eventId: String, timelineProvider: TimelineProvider): ForwardMessagesPresenter
}
private val forwardingActionState: MutableState<AsyncAction<List<RoomId>>> = mutableStateOf(AsyncAction.Uninitialized)

View File

@@ -23,6 +23,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.messages.api.pinned.IsPinnedMessagesFeatureEnabled
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
@@ -30,11 +31,6 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
fun interface IsPinnedMessagesFeatureEnabled {
@Composable
operator fun invoke(): Boolean
}
@ContributesBinding(AppScope::class)
class DefaultIsPinnedMessagesFeatureEnabled @Inject constructor(
private val featureFlagService: FeatureFlagService,

View File

@@ -0,0 +1,97 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.pinned
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.coroutine.mapState
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
@SingleIn(RoomScope::class)
class PinnedEventsTimelineProvider @Inject constructor(
private val room: MatrixRoom,
private val networkMonitor: NetworkMonitor,
private val featureFlagService: FeatureFlagService,
) : TimelineProvider {
private val _timelineStateFlow: MutableStateFlow<AsyncData<Timeline>> = MutableStateFlow(AsyncData.Uninitialized)
override fun activeTimelineFlow(): StateFlow<Timeline?> {
return _timelineStateFlow
.mapState { value ->
value.dataOrNull()
}
}
val timelineStateFlow = _timelineStateFlow
fun launchIn(scope: CoroutineScope) {
combine(
featureFlagService.isFeatureEnabledFlow(FeatureFlags.PinnedEvents),
networkMonitor.connectivity
) {
// do not use connectivity here as data can be loaded from cache, it's just to trigger retry if needed
isEnabled, _ ->
isEnabled
}
.onEach { isFeatureEnabled ->
if (isFeatureEnabled) {
loadTimelineIfNeeded()
} else {
_timelineStateFlow.value = AsyncData.Uninitialized
}
}
.onCompletion {
invokeOnTimeline { close() }
}
.launchIn(scope)
}
suspend fun invokeOnTimeline(action: suspend Timeline.() -> Unit) {
when (val asyncTimeline = timelineStateFlow.value) {
is AsyncData.Success -> action(asyncTimeline.data)
else -> Unit
}
}
private suspend fun loadTimelineIfNeeded() {
when (timelineStateFlow.value) {
is AsyncData.Uninitialized, is AsyncData.Failure -> {
timelineStateFlow.emit(AsyncData.Loading())
room.pinnedEventsTimeline()
.fold(
{ timelineStateFlow.emit(AsyncData.Success(it)) },
{ timelineStateFlow.emit(AsyncData.Failure(it)) }
)
}
else -> Unit
}
}
}

View File

@@ -26,18 +26,19 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import io.element.android.features.messages.impl.pinned.IsPinnedMessagesFeatureEnabled
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.messages.impl.pinned.PinnedEventsTimelineProvider
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.room.MatrixRoom
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
@@ -45,46 +46,38 @@ import kotlin.time.Duration.Companion.milliseconds
class PinnedMessagesBannerPresenter @Inject constructor(
private val room: MatrixRoom,
private val itemFactory: PinnedMessagesBannerItemFactory,
private val isFeatureEnabled: IsPinnedMessagesFeatureEnabled,
private val networkMonitor: NetworkMonitor,
private val pinnedEventsTimelineProvider: PinnedEventsTimelineProvider,
) : Presenter<PinnedMessagesBannerState> {
private val pinnedItems = mutableStateOf<ImmutableList<PinnedMessagesBannerItem>>(persistentListOf())
private val pinnedItems = mutableStateOf<AsyncData<ImmutableList<PinnedMessagesBannerItem>>>(AsyncData.Uninitialized)
@Composable
override fun present(): PinnedMessagesBannerState {
val isFeatureEnabled = isFeatureEnabled()
val expectedPinnedMessagesCount by remember {
room.roomInfoFlow.map { roomInfo -> roomInfo.pinnedEventIds.size }
}.collectAsState(initial = 0)
var hasTimelineFailedToLoad by rememberSaveable { mutableStateOf(false) }
var currentPinnedMessageIndex by rememberSaveable { mutableIntStateOf(-1) }
PinnedMessagesBannerItemsEffect(
isFeatureEnabled = isFeatureEnabled,
onItemsChange = { newItems ->
val pinnedMessageCount = newItems.size
val pinnedMessageCount = newItems.dataOrNull().orEmpty().size
if (currentPinnedMessageIndex >= pinnedMessageCount || currentPinnedMessageIndex < 0) {
currentPinnedMessageIndex = pinnedMessageCount - 1
}
pinnedItems.value = newItems
},
onTimelineFail = { hasTimelineFailed ->
hasTimelineFailedToLoad = hasTimelineFailed
}
)
fun handleEvent(event: PinnedMessagesBannerEvents) {
when (event) {
is PinnedMessagesBannerEvents.MoveToNextPinned -> {
currentPinnedMessageIndex = (currentPinnedMessageIndex - 1).mod(pinnedItems.value.size)
val loadedCount = pinnedItems.value.dataOrNull().orEmpty().size
currentPinnedMessageIndex = (currentPinnedMessageIndex - 1).mod(loadedCount)
}
}
}
return pinnedMessagesBannerState(
isFeatureEnabled = isFeatureEnabled,
hasTimelineFailed = hasTimelineFailedToLoad,
expectedPinnedMessagesCount = expectedPinnedMessagesCount,
pinnedItems = pinnedItems.value,
currentPinnedMessageIndex = currentPinnedMessageIndex,
@@ -94,63 +87,65 @@ class PinnedMessagesBannerPresenter @Inject constructor(
@Composable
private fun pinnedMessagesBannerState(
isFeatureEnabled: Boolean,
hasTimelineFailed: Boolean,
expectedPinnedMessagesCount: Int,
pinnedItems: ImmutableList<PinnedMessagesBannerItem>,
pinnedItems: AsyncData<ImmutableList<PinnedMessagesBannerItem>>,
currentPinnedMessageIndex: Int,
eventSink: (PinnedMessagesBannerEvents) -> Unit
): PinnedMessagesBannerState {
val currentPinnedMessage = pinnedItems.getOrNull(currentPinnedMessageIndex)
return when {
!isFeatureEnabled -> PinnedMessagesBannerState.Hidden
hasTimelineFailed -> PinnedMessagesBannerState.Hidden
currentPinnedMessage != null -> PinnedMessagesBannerState.Loaded(
currentPinnedMessage = currentPinnedMessage,
return when (pinnedItems) {
is AsyncData.Failure, is AsyncData.Uninitialized -> PinnedMessagesBannerState.Hidden
is AsyncData.Loading -> {
if (expectedPinnedMessagesCount == 0) {
PinnedMessagesBannerState.Hidden
} else {
PinnedMessagesBannerState.Loading(expectedPinnedMessagesCount = expectedPinnedMessagesCount)
}
}
is AsyncData.Success -> {
val currentPinnedMessage = pinnedItems.data.getOrNull(currentPinnedMessageIndex)
if (currentPinnedMessage == null) {
PinnedMessagesBannerState.Hidden
} else {
PinnedMessagesBannerState.Loaded(
loadedPinnedMessagesCount = pinnedItems.data.size,
currentPinnedMessageIndex = currentPinnedMessageIndex,
loadedPinnedMessagesCount = pinnedItems.size,
currentPinnedMessage = currentPinnedMessage,
eventSink = eventSink
)
expectedPinnedMessagesCount == 0 -> PinnedMessagesBannerState.Hidden
else -> PinnedMessagesBannerState.Loading(expectedPinnedMessagesCount = expectedPinnedMessagesCount)
}
}
}
}
@OptIn(FlowPreview::class)
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
@Composable
private fun PinnedMessagesBannerItemsEffect(
isFeatureEnabled: Boolean,
onItemsChange: (ImmutableList<PinnedMessagesBannerItem>) -> Unit,
onTimelineFail: (Boolean) -> Unit,
onItemsChange: (AsyncData<ImmutableList<PinnedMessagesBannerItem>>) -> Unit,
) {
val updatedOnItemsChange by rememberUpdatedState(onItemsChange)
val updatedOnTimelineFail by rememberUpdatedState(onTimelineFail)
val networkStatus by networkMonitor.connectivity.collectAsState()
LaunchedEffect(isFeatureEnabled, networkStatus) {
if (!isFeatureEnabled) {
updatedOnItemsChange(persistentListOf())
return@LaunchedEffect
}
val pinnedEventsTimeline = room.pinnedEventsTimeline()
.onFailure { updatedOnTimelineFail(true) }
.onSuccess { updatedOnTimelineFail(false) }
.getOrNull()
?: return@LaunchedEffect
pinnedEventsTimeline.timelineItems
LaunchedEffect(Unit) {
pinnedEventsTimelineProvider.timelineStateFlow
.flatMapLatest { asyncTimeline ->
when (asyncTimeline) {
AsyncData.Uninitialized -> flowOf(AsyncData.Uninitialized)
is AsyncData.Failure -> flowOf(AsyncData.Failure(asyncTimeline.error))
is AsyncData.Loading -> flowOf(AsyncData.Loading())
is AsyncData.Success -> {
asyncTimeline.data.timelineItems
.debounce(300.milliseconds)
.map { timelineItems ->
timelineItems.mapNotNull { timelineItem ->
val pinnedItems = timelineItems.mapNotNull { timelineItem ->
itemFactory.create(timelineItem)
}.toImmutableList()
AsyncData.Success(pinnedItems)
}
}
}
}
.onEach { newItems ->
updatedOnItemsChange(newItems)
}
.onCompletion {
pinnedEventsTimeline.close()
}
.launchIn(this)
}
}

View File

@@ -0,0 +1,24 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.pinned.list
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.timeline.model.TimelineItem
sealed interface PinnedMessagesListEvents {
data class HandleAction(val action: TimelineItemAction, val event: TimelineItem.Event) : PinnedMessagesListEvents
}

View File

@@ -0,0 +1,26 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.pinned.list
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
interface PinnedMessagesListNavigator {
fun onViewInTimelineClick(eventId: EventId)
fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo)
fun onForwardEventClick(eventId: EventId)
}

View File

@@ -0,0 +1,116 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.pinned.list
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
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.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.system.openUrlInExternalApp
import io.element.android.libraries.di.RoomScope
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.item.TimelineItemDebugInfo
@ContributesNode(RoomScope::class)
class PinnedMessagesListNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: PinnedMessagesListPresenter.Factory,
private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
private val permalinkParser: PermalinkParser,
) : Node(buildContext, plugins = plugins), PinnedMessagesListNavigator {
interface Callback : Plugin {
fun onEventClick(event: TimelineItem.Event)
fun onUserDataClick(userId: UserId)
fun onViewInTimelineClick(eventId: EventId)
fun onRoomPermalinkClick(data: PermalinkData.RoomLink)
fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo)
fun onForwardEventClick(eventId: EventId)
}
private val presenter = presenterFactory.create(this)
private val callbacks = plugins<Callback>()
private fun onEventClick(event: TimelineItem.Event) {
return callbacks.forEach { it.onEventClick(event) }
}
private fun onUserDataClick(userId: UserId) {
callbacks.forEach { it.onUserDataClick(userId) }
}
private fun onLinkClick(context: Context, url: String) {
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 -> {
callbacks.forEach { it.onRoomPermalinkClick(permalink) }
}
is PermalinkData.FallbackLink,
is PermalinkData.RoomEmailInviteLink -> {
context.openUrlInExternalApp(url)
}
}
}
override fun onViewInTimelineClick(eventId: EventId) {
callbacks.forEach { it.onViewInTimelineClick(eventId) }
}
override fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
callbacks.forEach { it.onShowEventDebugInfoClick(eventId, debugInfo) }
}
override fun onForwardEventClick(eventId: EventId) {
callbacks.forEach { it.onForwardEventClick(eventId) }
}
@Composable
override fun View(modifier: Modifier) {
CompositionLocalProvider(
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
) {
val context = LocalContext.current
val state = presenter.present()
PinnedMessagesListView(
state = state,
onBackClick = ::navigateUp,
onEventClick = ::onEventClick,
onUserDataClick = ::onUserDataClick,
onLinkClick = { url -> onLinkClick(context, url) },
modifier = modifier
)
}
}
}

View File

@@ -0,0 +1,234 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.pinned.list
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
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.rememberUpdatedState
import androidx.compose.runtime.setValue
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.messages.impl.UserEventPermissions
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.pinned.PinnedEventsTimelineProvider
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.architecture.AsyncData
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.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.powerlevels.canPinUnpin
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import timber.log.Timber
import kotlin.time.Duration.Companion.milliseconds
class PinnedMessagesListPresenter @AssistedInject constructor(
@Assisted private val navigator: PinnedMessagesListNavigator,
private val room: MatrixRoom,
private val timelineItemsFactory: TimelineItemsFactory,
private val timelineProvider: PinnedEventsTimelineProvider,
private val snackbarDispatcher: SnackbarDispatcher,
actionListPresenterFactory: ActionListPresenter.Factory,
) : Presenter<PinnedMessagesListState> {
@AssistedFactory
interface Factory {
fun create(navigator: PinnedMessagesListNavigator): PinnedMessagesListPresenter
}
private val actionListPresenter = actionListPresenterFactory.create(PinnedMessagesListTimelineActionPostProcessor())
@Composable
override fun present(): PinnedMessagesListState {
val timelineRoomInfo = remember {
TimelineRoomInfo(
isDm = room.isDm,
name = room.displayName,
// We don't need to compute those values
userHasPermissionToSendMessage = false,
userHasPermissionToSendReaction = false,
isCallOngoing = false,
)
}
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val userEventPermissions by userEventPermissions(syncUpdateFlow.value)
var pinnedMessageItems by remember {
mutableStateOf<AsyncData<ImmutableList<TimelineItem>>>(AsyncData.Uninitialized)
}
PinnedMessagesListEffect(
onItemsChange = { newItems ->
pinnedMessageItems = newItems
}
)
val coroutineScope = rememberCoroutineScope()
fun handleEvents(event: PinnedMessagesListEvents) {
when (event) {
is PinnedMessagesListEvents.HandleAction -> coroutineScope.handleTimelineAction(event.action, event.event)
}
}
return pinnedMessagesListState(
timelineRoomInfo = timelineRoomInfo,
userEventPermissions = userEventPermissions,
timelineItems = pinnedMessageItems,
eventSink = ::handleEvents
)
}
private fun CoroutineScope.handleTimelineAction(
action: TimelineItemAction,
targetEvent: TimelineItem.Event,
) = launch {
when (action) {
TimelineItemAction.Redact -> handleActionRedact(targetEvent)
TimelineItemAction.ViewSource -> {
navigator.onShowEventDebugInfoClick(targetEvent.eventId, targetEvent.debugInfo)
}
TimelineItemAction.Forward -> {
targetEvent.eventId?.let { eventId ->
navigator.onForwardEventClick(eventId)
}
}
TimelineItemAction.Unpin -> handleUnpinAction(targetEvent)
TimelineItemAction.ViewInTimeline -> {
targetEvent.eventId?.let { eventId ->
navigator.onViewInTimelineClick(eventId)
}
}
else -> Unit
}
}
private suspend fun handleUnpinAction(targetEvent: TimelineItem.Event) {
if (targetEvent.eventId == null) return
timelineProvider.invokeOnTimeline {
unpinEvent(targetEvent.eventId)
.onFailure {
Timber.e(it, "Failed to unpin event ${targetEvent.eventId}")
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_error))
}
}
}
private suspend fun handleActionRedact(event: TimelineItem.Event) {
timelineProvider.invokeOnTimeline {
redactEvent(eventId = event.eventId, transactionId = event.transactionId, reason = null)
.onFailure { Timber.e(it) }
}
}
@Composable
private fun userEventPermissions(updateKey: Long): State<UserEventPermissions> {
return produceState(UserEventPermissions.DEFAULT, key1 = updateKey) {
value = UserEventPermissions(
canSendMessage = false,
canSendReaction = false,
canRedactOwn = room.canRedactOwn().getOrElse { false },
canRedactOther = room.canRedactOther().getOrElse { false },
canPinUnpin = room.canPinUnpin().getOrElse { false },
)
}
}
@OptIn(FlowPreview::class)
@Composable
private fun PinnedMessagesListEffect(onItemsChange: (AsyncData<ImmutableList<TimelineItem>>) -> Unit) {
val updatedOnItemsChange by rememberUpdatedState(onItemsChange)
val timelineState by timelineProvider.timelineStateFlow.collectAsState()
LaunchedEffect(timelineState) {
when (val asyncTimeline = timelineState) {
AsyncData.Uninitialized -> flowOf(AsyncData.Uninitialized)
is AsyncData.Failure -> flowOf(AsyncData.Failure(asyncTimeline.error))
is AsyncData.Loading -> flowOf(AsyncData.Loading())
is AsyncData.Success -> {
val timelineItemsFlow = asyncTimeline.data.timelineItems.debounce(300.milliseconds)
combine(timelineItemsFlow, room.membersStateFlow) { items, membersState ->
timelineItemsFactory.replaceWith(
timelineItems = items,
roomMembers = membersState.roomMembers().orEmpty()
)
}.launchIn(this)
timelineItemsFactory.timelineItems.map { timelineItems ->
AsyncData.Success(timelineItems)
}
}
}
.onEach { items ->
updatedOnItemsChange(items)
}
.launchIn(this)
}
}
@Composable
private fun pinnedMessagesListState(
timelineRoomInfo: TimelineRoomInfo,
userEventPermissions: UserEventPermissions,
timelineItems: AsyncData<ImmutableList<TimelineItem>>,
eventSink: (PinnedMessagesListEvents) -> Unit
): PinnedMessagesListState {
return when (timelineItems) {
AsyncData.Uninitialized, is AsyncData.Loading -> PinnedMessagesListState.Loading
is AsyncData.Failure -> PinnedMessagesListState.Failed
is AsyncData.Success -> {
if (timelineItems.data.isEmpty()) {
PinnedMessagesListState.Empty
} else {
val actionListState = actionListPresenter.present()
PinnedMessagesListState.Filled(
timelineRoomInfo = timelineRoomInfo,
userEventPermissions = userEventPermissions,
timelineItems = timelineItems.data,
actionListState = actionListState,
eventSink = eventSink
)
}
}
}
}
}

View File

@@ -0,0 +1,55 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.pinned.list
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import io.element.android.features.messages.impl.UserEventPermissions
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.ui.strings.CommonPlurals
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
@Immutable
sealed interface PinnedMessagesListState {
data object Failed : PinnedMessagesListState
data object Loading : PinnedMessagesListState
data object Empty : PinnedMessagesListState
data class Filled(
val timelineRoomInfo: TimelineRoomInfo,
val userEventPermissions: UserEventPermissions,
val timelineItems: ImmutableList<TimelineItem>,
val actionListState: ActionListState,
val eventSink: (PinnedMessagesListEvents) -> Unit,
) : PinnedMessagesListState {
val loadedPinnedMessagesCount = timelineItems.count { timelineItem -> timelineItem is TimelineItem.Event }
}
@Composable
fun title(): String {
return when (this) {
is Filled -> {
pluralStringResource(id = CommonPlurals.screen_pinned_timeline_screen_title, loadedPinnedMessagesCount, loadedPinnedMessagesCount)
}
else -> stringResource(id = CommonStrings.screen_pinned_timeline_screen_title_empty)
}
}
}

View File

@@ -0,0 +1,105 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.pinned.list
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.UserEventPermissions
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.timeline.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.aTimelineItemDaySeparator
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
open class PinnedMessagesListStateProvider : PreviewParameterProvider<PinnedMessagesListState> {
override val values: Sequence<PinnedMessagesListState>
get() = sequenceOf(
aFailedPinnedMessagesListState(),
aLoadingPinnedMessagesListState(),
anEmptyPinnedMessagesListState(),
aLoadedPinnedMessagesListState(
timelineItems = persistentListOf(
aTimelineItemEvent(
isMine = false,
content = aTimelineItemTextContent("A pinned message"),
groupPosition = TimelineItemGroupPosition.Last,
timelineItemReactions = aTimelineItemReactions(0)
),
aTimelineItemEvent(
isMine = false,
content = aTimelineItemAudioContent("A pinned file"),
groupPosition = TimelineItemGroupPosition.Middle,
timelineItemReactions = aTimelineItemReactions(0)
),
aTimelineItemEvent(
isMine = false,
content = aTimelineItemPollContent("A pinned poll?"),
groupPosition = TimelineItemGroupPosition.First,
timelineItemReactions = aTimelineItemReactions(0)
),
aTimelineItemDaySeparator(),
aTimelineItemEvent(
isMine = true,
content = aTimelineItemTextContent("A pinned message"),
groupPosition = TimelineItemGroupPosition.Last,
timelineItemReactions = aTimelineItemReactions(0)
),
aTimelineItemEvent(
isMine = true,
content = aTimelineItemFileContent("A pinned file?"),
groupPosition = TimelineItemGroupPosition.Middle,
timelineItemReactions = aTimelineItemReactions(0)
),
aTimelineItemEvent(
isMine = true,
content = aTimelineItemPollContent("A pinned poll?"),
groupPosition = TimelineItemGroupPosition.First,
timelineItemReactions = aTimelineItemReactions(0)
),
)
)
)
}
fun aFailedPinnedMessagesListState() = PinnedMessagesListState.Failed
fun aLoadingPinnedMessagesListState() = PinnedMessagesListState.Loading
fun anEmptyPinnedMessagesListState() = PinnedMessagesListState.Empty
fun aLoadedPinnedMessagesListState(
timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(),
timelineItems: List<TimelineItem> = emptyList(),
actionListState: ActionListState = anActionListState(),
aUserEventPermissions: UserEventPermissions = UserEventPermissions.DEFAULT,
eventSink: (PinnedMessagesListEvents) -> Unit = {}
) = PinnedMessagesListState.Filled(
timelineRoomInfo = timelineRoomInfo,
timelineItems = timelineItems.toImmutableList(),
actionListState = actionListState,
userEventPermissions = aUserEventPermissions,
eventSink = eventSink,
)

View File

@@ -0,0 +1,32 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.pinned.list
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionPostProcessor
class PinnedMessagesListTimelineActionPostProcessor : TimelineItemActionPostProcessor {
override fun process(actions: List<TimelineItemAction>): List<TimelineItemAction> {
return buildList {
add(TimelineItemAction.ViewInTimeline)
actions.firstOrNull { it is TimelineItemAction.Unpin }?.let(::add)
actions.firstOrNull { it is TimelineItemAction.Forward }?.let(::add)
actions.firstOrNull { it is TimelineItemAction.ViewSource }?.let(::add)
actions.firstOrNull { it is TimelineItemAction.Redact }?.let(::add)
}
}
}

View File

@@ -0,0 +1,272 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.pinned.list
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListView
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.timeline.components.TimelineItemRow
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.poll.api.pollcontent.PollTitleView
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.icons.CompoundDrawables
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun PinnedMessagesListView(
state: PinnedMessagesListState,
onBackClick: () -> Unit,
onEventClick: (event: TimelineItem.Event) -> Unit,
onUserDataClick: (UserId) -> Unit,
onLinkClick: (String) -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
topBar = {
PinnedMessagesListTopBar(state, onBackClick)
},
content = { padding ->
PinnedMessagesListContent(
state = state,
onEventClick = onEventClick,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
onErrorDismiss = onBackClick,
modifier = Modifier
.padding(padding)
.consumeWindowInsets(padding),
)
}
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PinnedMessagesListTopBar(
state: PinnedMessagesListState,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
TopAppBar(
title = {
Text(
text = state.title(),
style = ElementTheme.typography.fontBodyLgMedium
)
},
navigationIcon = { BackButton(onClick = onBackClick) },
modifier = modifier,
)
}
@Composable
private fun PinnedMessagesListContent(
state: PinnedMessagesListState,
onEventClick: (event: TimelineItem.Event) -> Unit,
onUserDataClick: (UserId) -> Unit,
onLinkClick: (String) -> Unit,
onErrorDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier.fillMaxSize()) {
when (state) {
PinnedMessagesListState.Failed -> {
ErrorDialog(
title = stringResource(id = CommonStrings.error_unknown),
content = stringResource(id = CommonStrings.error_failed_loading_messages),
onDismiss = onErrorDismiss
)
}
PinnedMessagesListState.Empty -> PinnedMessagesListEmpty()
is PinnedMessagesListState.Filled -> PinnedMessagesListLoaded(
state = state,
onEventClick = onEventClick,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
)
PinnedMessagesListState.Loading -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
}
}
}
@Composable
private fun PinnedMessagesListEmpty(
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier.padding(
horizontal = 32.dp,
vertical = 48.dp,
),
contentAlignment = Alignment.Center,
) {
val pinActionText = stringResource(id = CommonStrings.action_pin)
IconTitleSubtitleMolecule(
title = stringResource(id = CommonStrings.screen_pinned_timeline_empty_state_headline),
subTitle = stringResource(id = CommonStrings.screen_pinned_timeline_empty_state_description, pinActionText),
iconResourceId = CompoundDrawables.ic_compound_pin,
)
}
}
@Composable
private fun PinnedMessagesListLoaded(
state: PinnedMessagesListState.Filled,
onEventClick: (event: TimelineItem.Event) -> Unit,
onUserDataClick: (UserId) -> Unit,
onLinkClick: (String) -> Unit,
modifier: Modifier = Modifier,
) {
fun onActionSelected(timelineItemAction: TimelineItemAction, event: TimelineItem.Event) {
state.actionListState.eventSink(
ActionListEvents.Clear
)
state.eventSink(
PinnedMessagesListEvents.HandleAction(
action = timelineItemAction,
event = event,
)
)
}
fun onMessageLongClick(event: TimelineItem.Event) {
state.actionListState.eventSink(
ActionListEvents.ComputeForMessage(
event = event,
userEventPermissions = state.userEventPermissions,
)
)
}
ActionListView(
state = state.actionListState,
onSelectAction = ::onActionSelected,
onCustomReactionClick = {},
onEmojiReactionClick = { _, _ -> },
)
LazyColumn(
modifier = modifier.fillMaxSize(),
state = rememberLazyListState(),
reverseLayout = true,
contentPadding = PaddingValues(vertical = 8.dp),
) {
items(
items = state.timelineItems,
contentType = { timelineItem -> timelineItem.contentType() },
key = { timelineItem -> timelineItem.identifier() },
) { timelineItem ->
TimelineItemRow(
timelineItem = timelineItem,
timelineRoomInfo = state.timelineRoomInfo,
renderReadReceipts = false,
isLastOutgoingMessage = false,
focusedEventId = null,
onClick = onEventClick,
onLongClick = ::onMessageLongClick,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
inReplyToClick = {},
onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {},
onReadReceiptClick = {},
eventSink = {},
onSwipeToReply = {},
onJoinCallClick = {},
onShieldClick = {},
eventContentView = { event, contentModifier, onContentLayoutChange ->
TimelineItemEventContentViewWrapper(
event = event,
onLinkClick = onLinkClick,
modifier = contentModifier,
onContentLayoutChange = onContentLayoutChange
)
},
)
}
}
}
@Composable
private fun TimelineItemEventContentViewWrapper(
event: TimelineItem.Event,
onLinkClick: (String) -> Unit,
modifier: Modifier = Modifier,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
) {
if (event.content is TimelineItemPollContent) {
PollTitleView(
title = event.content.question,
isPollEnded = event.content.isEnded,
modifier = modifier
)
} else {
TimelineItemEventContentView(
content = event.content,
onLinkClick = onLinkClick,
eventSink = { },
modifier = modifier,
onContentLayoutChange = onContentLayoutChange
)
}
}
@PreviewsDayNight
@Composable
internal fun PinnedMessagesListViewPreview(@PreviewParameter(PinnedMessagesListStateProvider::class) state: PinnedMessagesListState) =
ElementPreview {
PinnedMessagesListView(
state = state,
onBackClick = {},
onEventClick = { },
onUserDataClick = {},
onLinkClick = {},
)
}

View File

@@ -51,6 +51,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemE
import io.element.android.libraries.matrix.ui.room.canSendMessageAsState
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine
@@ -88,7 +89,7 @@ class TimelinePresenter @AssistedInject constructor(
val lastReadReceiptId = rememberSaveable { mutableStateOf<EventId?>(null) }
val timelineItems by timelineItemsFactory.collectItemsAsState()
val timelineItems by timelineItemsFactory.timelineItems.collectAsState(initial = persistentListOf())
val roomInfo by room.roomInfoFlow.collectAsState(initial = null)
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()

View File

@@ -129,7 +129,16 @@ fun TimelineItemEventRow(
onReadReceiptClick: (event: TimelineItem.Event) -> Unit,
onSwipeToReply: () -> Unit,
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
eventContentView: @Composable (Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit = { contentModifier, onContentLayoutChange ->
TimelineItemEventContentView(
content = event.content,
onLinkClick = onLinkClick,
eventSink = eventSink,
modifier = contentModifier,
onContentLayoutChange = onContentLayoutChange
)
},
) {
val coroutineScope = rememberCoroutineScope()
val interactionSource = remember { MutableInteractionSource() }
@@ -188,8 +197,7 @@ fun TimelineItemEventRow(
onReactionClick = { emoji -> onReactionClick(emoji, event) },
onReactionLongClick = { emoji -> onReactionLongClick(emoji, event) },
onMoreReactionsClick = { onMoreReactionsClick(event) },
onLinkClick = onLinkClick,
eventSink = eventSink,
eventContentView = eventContentView,
)
}
}
@@ -207,8 +215,7 @@ fun TimelineItemEventRow(
onReactionClick = { emoji -> onReactionClick(emoji, event) },
onReactionLongClick = { emoji -> onReactionLongClick(emoji, event) },
onMoreReactionsClick = { onMoreReactionsClick(event) },
onLinkClick = onLinkClick,
eventSink = eventSink,
eventContentView = eventContentView,
)
}
// Read receipts / Send state
@@ -263,9 +270,8 @@ private fun TimelineItemEventRowContent(
onReactionClick: (emoji: String) -> Unit,
onReactionLongClick: (emoji: String) -> Unit,
onMoreReactionsClick: (event: TimelineItem.Event) -> Unit,
onLinkClick: (String) -> Unit,
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
modifier: Modifier = Modifier,
eventContentView: @Composable (Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit,
) {
fun ConstrainScope.linkStartOrEnd(event: TimelineItem.Event) = if (event.isMine) {
end.linkTo(parent.end)
@@ -328,8 +334,7 @@ private fun TimelineItemEventRowContent(
onShieldClick = onShieldClick,
onMessageLongClick = onLongClick,
inReplyToClick = inReplyToClick,
onLinkClick = onLinkClick,
eventSink = eventSink,
eventContentView = eventContentView,
)
}
@@ -389,12 +394,11 @@ private fun MessageEventBubbleContent(
onShieldClick: (MessageShield) -> Unit,
onMessageLongClick: () -> Unit,
inReplyToClick: () -> Unit,
onLinkClick: (String) -> Unit,
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
@SuppressLint("ModifierParameter")
// need to rename this modifier to prevent linter false positives
@Suppress("ModifierNaming")
bubbleModifier: Modifier = Modifier,
eventContentView: @Composable (Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit,
) {
// Long clicks are not not automatically propagated from a `clickable`
// to its `combinedClickable` parent so we do it manually
@@ -521,15 +525,10 @@ private fun MessageEventBubbleContent(
onShieldClick = onShieldClick,
canShrinkContent = canShrinkContent,
modifier = timestampLayoutModifier,
) { onContentLayoutChange ->
TimelineItemEventContentView(
content = event.content,
onLinkClick = onLinkClick,
eventSink = eventSink,
onContentLayoutChange = onContentLayoutChange,
modifier = contentModifier
)
content = { onContentLayoutChange ->
eventContentView(contentModifier, onContentLayoutChange)
}
)
}
val inReplyTo = @Composable { inReplyTo: InReplyToDetails ->

View File

@@ -28,7 +28,9 @@ import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.aGroupedEvents
import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
import io.element.android.features.messages.impl.timeline.components.group.GroupHeaderView
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
import io.element.android.features.messages.impl.timeline.model.TimelineItem
@@ -56,7 +58,17 @@ fun TimelineItemGroupedEventsRow(
onMoreReactionsClick: (TimelineItem.Event) -> Unit,
onReadReceiptClick: (TimelineItem.Event) -> Unit,
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit =
{ event, contentModifier, onContentLayoutChange ->
TimelineItemEventContentView(
content = event.content,
onLinkClick = onLinkClick,
eventSink = eventSink,
modifier = contentModifier,
onContentLayoutChange = onContentLayoutChange
)
},
) {
val isExpanded = rememberSaveable(key = timelineItem.identifier().value) { mutableStateOf(false) }
@@ -84,6 +96,7 @@ fun TimelineItemGroupedEventsRow(
onReadReceiptClick = onReadReceiptClick,
eventSink = eventSink,
modifier = modifier,
eventContentView = eventContentView,
)
}
@@ -108,6 +121,16 @@ private fun TimelineItemGroupedEventsRowContent(
onReadReceiptClick: (TimelineItem.Event) -> Unit,
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
modifier: Modifier = Modifier,
eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit =
{ event, contentModifier, onContentLayoutChange ->
TimelineItemEventContentView(
content = event.content,
onLinkClick = onLinkClick,
eventSink = eventSink,
modifier = contentModifier,
onContentLayoutChange = onContentLayoutChange
)
},
) {
Column(modifier = modifier.animateContentSize()) {
GroupHeaderView(
@@ -142,6 +165,7 @@ private fun TimelineItemGroupedEventsRowContent(
eventSink = eventSink,
onSwipeToReply = {},
onJoinCallClick = {},
eventContentView = eventContentView,
)
}
}

View File

@@ -29,6 +29,8 @@ import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemCallNotifyContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
@@ -59,7 +61,17 @@ internal fun TimelineItemRow(
onSwipeToReply: (TimelineItem.Event) -> Unit,
onJoinCallClick: () -> Unit,
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit =
{ event, contentModifier, onContentLayoutChange ->
TimelineItemEventContentView(
content = event.content,
onLinkClick = onLinkClick,
eventSink = eventSink,
modifier = contentModifier,
onContentLayoutChange = onContentLayoutChange
)
},
) {
val backgroundModifier = if (timelineItem.isEvent(focusedEventId)) {
val focusedEventOffset = if ((timelineItem as? TimelineItem.Event)?.showSenderInformation == true) {
@@ -122,6 +134,9 @@ internal fun TimelineItemRow(
onReadReceiptClick = onReadReceiptClick,
onSwipeToReply = { onSwipeToReply(timelineItem) },
eventSink = eventSink,
eventContentView = { contentModifier, onContentLayoutChange ->
eventContentView(timelineItem, contentModifier, onContentLayoutChange)
},
)
}
}

View File

@@ -16,9 +16,6 @@
package io.element.android.features.messages.impl.timeline.factories
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import io.element.android.features.messages.impl.timeline.TimelineItemIndexer
import io.element.android.features.messages.impl.timeline.diff.TimelineItemsCacheInvalidator
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemEventFactory
@@ -31,9 +28,10 @@ import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
@@ -46,7 +44,7 @@ class TimelineItemsFactory @Inject constructor(
private val timelineItemGrouper: TimelineItemGrouper,
private val timelineItemIndexer: TimelineItemIndexer,
) {
private val timelineItems = MutableStateFlow(persistentListOf<TimelineItem>())
private val _timelineItems = MutableSharedFlow<ImmutableList<TimelineItem>>(replay = 1)
private val lock = Mutex()
private val diffCache = MutableListDiffCache<TimelineItem>()
private val diffCacheUpdater = DiffCacheUpdater<MatrixTimelineItem, TimelineItem>(
@@ -61,10 +59,7 @@ class TimelineItemsFactory @Inject constructor(
}
}
@Composable
fun collectItemsAsState(): State<ImmutableList<TimelineItem>> {
return timelineItems.collectAsState()
}
val timelineItems: Flow<ImmutableList<TimelineItem>> = _timelineItems.distinctUntilChanged()
suspend fun replaceWith(
timelineItems: List<MatrixTimelineItem>,
@@ -102,7 +97,7 @@ class TimelineItemsFactory @Inject constructor(
}
val result = timelineItemGrouper.group(newTimelineItemStates).toPersistentList()
timelineItemIndexer.process(result)
this.timelineItems.emit(result)
this._timelineItems.emit(result)
}
private suspend fun buildAndCacheItem(

View File

@@ -22,8 +22,8 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.ReceiveTurbine
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.FakeActionListPresenter
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.draft.FakeComposerDraftService
import io.element.android.features.messages.impl.fixtures.aMessageEvent
@@ -100,7 +100,6 @@ import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.libraries.textcomposer.model.MessageComposerMode
@@ -996,7 +995,6 @@ class MessagesPresenterTest {
): MessagesPresenter {
val mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom)
val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter)
val appPreferencesStore = InMemoryAppPreferencesStore()
val sessionPreferencesStore = InMemorySessionPreferencesStore()
val mentionSpanProvider = MentionSpanProvider(FakePermalinkParser())
val messageComposerPresenter = MessageComposerPresenter(
@@ -1053,11 +1051,6 @@ class MessagesPresenterTest {
}
}
val featureFlagService = FakeFeatureFlagService()
val actionListPresenter = ActionListPresenter(
appPreferencesStore = appPreferencesStore,
featureFlagsService = featureFlagService,
room = matrixRoom,
)
val typingNotificationPresenter = TypingNotificationPresenter(
room = matrixRoom,
sessionPreferencesStore = sessionPreferencesStore,
@@ -1073,7 +1066,7 @@ class MessagesPresenterTest {
voiceMessageComposerPresenter = voiceMessageComposerPresenter,
timelinePresenterFactory = timelinePresenterFactory,
typingNotificationPresenter = typingNotificationPresenter,
actionListPresenter = actionListPresenter,
actionListPresenterFactory = FakeActionListPresenter.Factory,
customReactionPresenter = customReactionPresenter,
reactionSummaryPresenter = reactionSummaryPresenter,
readReceiptBottomSheetPresenter = readReceiptBottomSheetPresenter,

View File

@@ -22,6 +22,7 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.aUserEventPermissions
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionPostProcessor
import io.element.android.features.messages.impl.fixtures.aMessageEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemCallNotifyContent
@@ -32,8 +33,6 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent
import io.element.android.features.poll.api.pollcontent.aPollAnswerItemList
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_MESSAGE
@@ -974,14 +973,10 @@ private fun createActionListPresenter(
room: MatrixRoom = FakeMatrixRoom(),
): ActionListPresenter {
val preferencesStore = InMemoryAppPreferencesStore(isDeveloperModeEnabled = isDeveloperModeEnabled)
val featureFlagsService = FakeFeatureFlagService(
initialState = mapOf(
FeatureFlags.PinnedEvents.key to isPinFeatureEnabled,
)
)
return ActionListPresenter(
return DefaultActionListPresenter(
postProcessor = TimelineItemActionPostProcessor.Default,
appPreferencesStore = preferencesStore,
featureFlagsService = featureFlagsService,
isPinnedMessagesFeatureEnabled = { isPinFeatureEnabled },
room = room
)
}

View File

@@ -0,0 +1,33 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.actionlist
import androidx.compose.runtime.Composable
import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionPostProcessor
class FakeActionListPresenter : ActionListPresenter {
object Factory : ActionListPresenter.Factory {
override fun create(postProcessor: TimelineItemActionPostProcessor): ActionListPresenter {
return FakeActionListPresenter()
}
}
@Composable
override fun present(): ActionListState {
return anActionListState()
}
}

View File

@@ -17,9 +17,12 @@
package io.element.android.features.messages.impl.pinned.banner
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.pinned.PinnedEventsTimelineProvider
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.eventformatter.test.FakePinnedMessagesBannerFormatter
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.test.AN_EVENT_ID
@@ -67,7 +70,7 @@ class PinnedMessagesBannerPresenterTest {
}
val presenter = createPinnedMessagesBannerPresenter(room = room)
presenter.test {
skipItems(1)
skipItems(2)
val loadingState = awaitItem()
assertThat(loadingState).isEqualTo(PinnedMessagesBannerState.Loading(1))
assertThat(loadingState.pinnedMessagesCount()).isEqualTo(1)
@@ -98,7 +101,7 @@ class PinnedMessagesBannerPresenterTest {
}
val presenter = createPinnedMessagesBannerPresenter(room = room)
presenter.test {
skipItems(2)
skipItems(3)
val loadedState = awaitItem() as PinnedMessagesBannerState.Loaded
assertThat(loadedState.currentPinnedMessageIndex).isEqualTo(0)
assertThat(loadedState.loadedPinnedMessagesCount).isEqualTo(1)
@@ -137,7 +140,7 @@ class PinnedMessagesBannerPresenterTest {
}
val presenter = createPinnedMessagesBannerPresenter(room = room)
presenter.test {
skipItems(2)
skipItems(3)
awaitItem().also { loadedState ->
loadedState as PinnedMessagesBannerState.Loaded
assertThat(loadedState.currentPinnedMessageIndex).isEqualTo(1)
@@ -172,7 +175,7 @@ class PinnedMessagesBannerPresenterTest {
}
val presenter = createPinnedMessagesBannerPresenter(room = room)
presenter.test {
skipItems(1)
skipItems(2)
awaitItem().also { loadingState ->
assertThat(loadingState).isEqualTo(PinnedMessagesBannerState.Loading(1))
assertThat(loadingState.pinnedMessagesCount()).isEqualTo(1)
@@ -195,11 +198,19 @@ class PinnedMessagesBannerPresenterTest {
networkMonitor: NetworkMonitor = FakeNetworkMonitor(),
isFeatureEnabled: Boolean = true,
): PinnedMessagesBannerPresenter {
val timelineProvider = PinnedEventsTimelineProvider(
room = room,
networkMonitor = networkMonitor,
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.PinnedEvents.key to isFeatureEnabled)
)
)
timelineProvider.launchIn(backgroundScope)
return PinnedMessagesBannerPresenter(
room = room,
itemFactory = itemFactory,
isFeatureEnabled = { isFeatureEnabled },
networkMonitor = networkMonitor,
pinnedEventsTimelineProvider = timelineProvider,
)
}
}

View File

@@ -0,0 +1,37 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.pinned.list
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
class FakePinnedMessagesListNavigator : PinnedMessagesListNavigator {
var onViewInTimelineClickLambda: ((EventId) -> Unit)? = null
override fun onViewInTimelineClick(eventId: EventId) {
onViewInTimelineClickLambda?.invoke(eventId)
}
var onShowEventDebugInfoClickLambda: ((EventId?, TimelineItemDebugInfo) -> Unit)? = null
override fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
onShowEventDebugInfoClickLambda?.invoke(eventId, debugInfo)
}
var onForwardEventClickLambda: ((EventId) -> Unit)? = null
override fun onForwardEventClick(eventId: EventId) {
onForwardEventClickLambda?.invoke(eventId)
}
}

View File

@@ -0,0 +1,336 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.pinned.list
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.actionlist.FakeActionListPresenter
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactory
import io.element.android.features.messages.impl.pinned.PinnedEventsTimelineProvider
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.A_UNIQUE_ID
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.matrix.test.timeline.aMessageContent
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
class PinnedMessagesListPresenterTest {
@Test
fun `present - initial state feature disabled`() = runTest {
val room = FakeMatrixRoom(
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
)
val presenter = createPinnedMessagesListPresenter(room = room, isFeatureEnabled = false)
presenter.test {
val initialState = awaitItem()
assertThat(initialState).isEqualTo(PinnedMessagesListState.Loading)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - initial state feature enabled`() = runTest {
val room = FakeMatrixRoom(
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
}
val presenter = createPinnedMessagesListPresenter(room = room, isFeatureEnabled = true)
presenter.test {
val initialState = awaitItem()
assertThat(initialState).isEqualTo(PinnedMessagesListState.Loading)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - timeline failure state`() = runTest {
val room = FakeMatrixRoom(
pinnedEventsTimelineResult = { Result.failure(RuntimeException()) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
}
val presenter = createPinnedMessagesListPresenter(room = room, isFeatureEnabled = true)
presenter.test {
skipItems(3)
val failureState = awaitItem()
assertThat(failureState).isEqualTo(PinnedMessagesListState.Failed)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - empty state`() = runTest {
val room = FakeMatrixRoom(
pinnedEventsTimelineResult = { Result.success(FakeTimeline()) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf()))
}
val presenter = createPinnedMessagesListPresenter(room = room, isFeatureEnabled = true)
presenter.test {
skipItems(3)
val emptyState = awaitItem()
assertThat(emptyState).isEqualTo(PinnedMessagesListState.Empty)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - filled state`() = runTest {
val pinnedEventsTimeline = createPinnedMessagesTimeline()
val room = FakeMatrixRoom(
pinnedEventsTimelineResult = { Result.success(pinnedEventsTimeline) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
}
val presenter = createPinnedMessagesListPresenter(room = room, isFeatureEnabled = true)
presenter.test {
skipItems(3)
val filledState = awaitItem() as PinnedMessagesListState.Filled
assertThat(filledState.timelineItems).hasSize(1)
assertThat(filledState.loadedPinnedMessagesCount).isEqualTo(1)
assertThat(filledState.userEventPermissions.canRedactOwn).isTrue()
assertThat(filledState.userEventPermissions.canRedactOther).isTrue()
assertThat(filledState.userEventPermissions.canPinUnpin).isTrue()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - redact event`() = runTest {
val redactEventLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String? -> Result.success(true) }
val pinnedEventsTimeline = createPinnedMessagesTimeline().apply {
this.redactEventLambda = redactEventLambda
}
val room = FakeMatrixRoom(
pinnedEventsTimelineResult = { Result.success(pinnedEventsTimeline) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
}
val presenter = createPinnedMessagesListPresenter(room = room, isFeatureEnabled = true)
presenter.test {
skipItems(3)
val filledState = awaitItem() as PinnedMessagesListState.Filled
val eventItem = filledState.timelineItems.first() as TimelineItem.Event
filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.Redact, eventItem))
cancelAndIgnoreRemainingEvents()
assert(redactEventLambda)
.isCalledOnce()
.with(value(AN_EVENT_ID), value(null), value(null))
}
}
@Test
fun `present - unpin event`() = runTest {
val successUnpinEventLambda = lambdaRecorder { _: EventId? -> Result.success(true) }
val failureUnpinEventLambda = lambdaRecorder { _: EventId? -> Result.failure<Boolean>(A_THROWABLE) }
val pinnedEventsTimeline = createPinnedMessagesTimeline()
val room = FakeMatrixRoom(
pinnedEventsTimelineResult = { Result.success(pinnedEventsTimeline) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
}
val presenter = createPinnedMessagesListPresenter(room = room, isFeatureEnabled = true)
presenter.test {
skipItems(3)
val filledState = awaitItem() as PinnedMessagesListState.Filled
val eventItem = filledState.timelineItems.first() as TimelineItem.Event
pinnedEventsTimeline.unpinEventLambda = successUnpinEventLambda
filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.Unpin, eventItem))
pinnedEventsTimeline.unpinEventLambda = failureUnpinEventLambda
filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.Unpin, eventItem))
cancelAndIgnoreRemainingEvents()
assert(successUnpinEventLambda)
.isCalledOnce()
.with(value(AN_EVENT_ID))
assert(failureUnpinEventLambda)
.isCalledOnce()
.with(value(AN_EVENT_ID))
}
}
@Test
fun `present - navigate to event`() = runTest {
val onViewInTimelineClickLambda = lambdaRecorder { _: EventId -> }
val navigator = FakePinnedMessagesListNavigator().apply {
this.onViewInTimelineClickLambda = onViewInTimelineClickLambda
}
val pinnedEventsTimeline = createPinnedMessagesTimeline()
val room = FakeMatrixRoom(
pinnedEventsTimelineResult = { Result.success(pinnedEventsTimeline) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
}
val presenter = createPinnedMessagesListPresenter(room = room, navigator = navigator, isFeatureEnabled = true)
presenter.test {
skipItems(3)
val filledState = awaitItem() as PinnedMessagesListState.Filled
val eventItem = filledState.timelineItems.first() as TimelineItem.Event
filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.ViewInTimeline, eventItem))
cancelAndIgnoreRemainingEvents()
assert(onViewInTimelineClickLambda)
.isCalledOnce()
.with(value(AN_EVENT_ID))
}
}
@Test
fun `present - show view source action`() = runTest {
val onShowEventDebugInfoClickLambda = lambdaRecorder { _: EventId?, _: TimelineItemDebugInfo -> }
val navigator = FakePinnedMessagesListNavigator().apply {
this.onShowEventDebugInfoClickLambda = onShowEventDebugInfoClickLambda
}
val pinnedEventsTimeline = createPinnedMessagesTimeline()
val room = FakeMatrixRoom(
pinnedEventsTimelineResult = { Result.success(pinnedEventsTimeline) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
}
val presenter = createPinnedMessagesListPresenter(room = room, navigator = navigator, isFeatureEnabled = true)
presenter.test {
skipItems(3)
val filledState = awaitItem() as PinnedMessagesListState.Filled
val eventItem = filledState.timelineItems.first() as TimelineItem.Event
filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.ViewSource, eventItem))
cancelAndIgnoreRemainingEvents()
assert(onShowEventDebugInfoClickLambda)
.isCalledOnce()
.with(value(AN_EVENT_ID), value(eventItem.debugInfo))
}
}
@Test
fun `present - forward event`() = runTest {
val onForwardEventClickLambda = lambdaRecorder { _: EventId -> }
val navigator = FakePinnedMessagesListNavigator().apply {
this.onForwardEventClickLambda = onForwardEventClickLambda
}
val pinnedEventsTimeline = createPinnedMessagesTimeline()
val room = FakeMatrixRoom(
pinnedEventsTimelineResult = { Result.success(pinnedEventsTimeline) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
}
val presenter = createPinnedMessagesListPresenter(room = room, navigator = navigator, isFeatureEnabled = true)
presenter.test {
skipItems(3)
val filledState = awaitItem() as PinnedMessagesListState.Filled
val eventItem = filledState.timelineItems.first() as TimelineItem.Event
filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.Forward, eventItem))
cancelAndIgnoreRemainingEvents()
assert(onForwardEventClickLambda)
.isCalledOnce()
.with(value(AN_EVENT_ID))
}
}
private fun createPinnedMessagesTimeline(): FakeTimeline {
val messageContent = aMessageContent("A message")
return FakeTimeline(
timelineItems = flowOf(
listOf(
MatrixTimelineItem.Event(
uniqueId = A_UNIQUE_ID,
event = anEventTimelineItem(
eventId = AN_EVENT_ID,
content = messageContent,
),
)
)
)
)
}
private fun TestScope.createPinnedMessagesListPresenter(
navigator: PinnedMessagesListNavigator = FakePinnedMessagesListNavigator(),
room: MatrixRoom = FakeMatrixRoom(),
networkMonitor: NetworkMonitor = FakeNetworkMonitor(),
isFeatureEnabled: Boolean = true,
): PinnedMessagesListPresenter {
val timelineProvider = PinnedEventsTimelineProvider(
room = room,
networkMonitor = networkMonitor,
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.PinnedEvents.key to isFeatureEnabled)
)
)
timelineProvider.launchIn(backgroundScope)
return PinnedMessagesListPresenter(
navigator = navigator,
room = room,
timelineItemsFactory = aTimelineItemsFactory(),
timelineProvider = timelineProvider,
snackbarDispatcher = SnackbarDispatcher(),
actionListPresenterFactory = FakeActionListPresenter.Factory,
)
}
}

View File

@@ -0,0 +1,121 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.pinned.list
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.longClick
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.anActionListState
import io.element.android.features.messages.impl.timeline.aTimelineItemList
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemFileContent
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.pressBack
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class PinnedMessagesListViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking on back calls the expected callback`() {
val eventsRecorder = EventsRecorder<PinnedMessagesListEvents>(expectEvents = false)
val state = aLoadedPinnedMessagesListState(
eventSink = eventsRecorder
)
ensureCalledOnce { callback ->
rule.setPinnedMessagesListView(
state = state,
onBackClick = callback
)
rule.pressBack()
}
}
@Test
fun `click on an event calls the expected callback`() {
val eventsRecorder = EventsRecorder<PinnedMessagesListEvents>(expectEvents = false)
val content = aTimelineItemFileContent()
val state = aLoadedPinnedMessagesListState(
timelineItems = aTimelineItemList(content),
eventSink = eventsRecorder
)
val event = state.timelineItems.first() as TimelineItem.Event
ensureCalledOnceWithParam(event) { callback ->
rule.setPinnedMessagesListView(
state = state,
onEventClick = callback
)
rule.onAllNodesWithText(content.body).onFirst().performClick()
}
}
@Test
fun `long click on an event emits the expected event`() {
val eventsRecorder = EventsRecorder<ActionListEvents>(expectEvents = true)
val content = aTimelineItemFileContent()
val state = aLoadedPinnedMessagesListState(
timelineItems = aTimelineItemList(content),
actionListState = anActionListState(eventSink = eventsRecorder)
)
rule.setPinnedMessagesListView(
state = state,
)
rule.onAllNodesWithText(content.body).onFirst()
.performTouchInput {
longClick()
}
val event = state.timelineItems.first() as TimelineItem.Event
eventsRecorder.assertSingle(ActionListEvents.ComputeForMessage(event, state.userEventPermissions))
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setPinnedMessagesListView(
state: PinnedMessagesListState,
onBackClick: () -> Unit = EnsureNeverCalled(),
onEventClick: (event: TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
onUserDataClick: (UserId) -> Unit = EnsureNeverCalledWithParam(),
onLinkClick: (String) -> Unit = EnsureNeverCalledWithParam(),
) {
setContent {
PinnedMessagesListView(
state = state,
onBackClick = onBackClick,
onEventClick = onEventClick,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
)
}
}

View File

@@ -19,10 +19,8 @@ package io.element.android.features.poll.api.pollcontent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.runtime.Composable
@@ -36,12 +34,10 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
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.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
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.poll.PollAnswer
@@ -117,7 +113,7 @@ fun PollContentView(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
PollTitle(title = question, isPollEnded = isPollEnded)
PollTitleView(title = question, isPollEnded = isPollEnded)
PollAnswers(answerItems = answerItems, onSelectAnswer = ::onSelectAnswer)
@@ -139,34 +135,6 @@ fun PollContentView(
}
}
@Composable
private fun PollTitle(
title: String,
isPollEnded: Boolean,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
if (isPollEnded) {
Icon(
imageVector = CompoundIcons.PollsEnd(),
contentDescription = stringResource(id = CommonStrings.a11y_poll_end),
modifier = Modifier.size(22.dp)
)
} else {
Icon(
imageVector = CompoundIcons.Polls(),
contentDescription = stringResource(id = CommonStrings.a11y_poll),
modifier = Modifier.size(22.dp)
)
}
Text(
text = title,
style = ElementTheme.typography.fontBodyLgMedium
)
}
}
@Composable
private fun PollAnswers(
answerItems: ImmutableList<PollAnswerItem>,

View File

@@ -0,0 +1,71 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.poll.api.pollcontent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
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.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun PollTitleView(
title: String,
isPollEnded: Boolean,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
if (isPollEnded) {
Icon(
imageVector = CompoundIcons.PollsEnd(),
contentDescription = stringResource(id = CommonStrings.a11y_poll_end),
modifier = Modifier.size(22.dp)
)
} else {
Icon(
imageVector = CompoundIcons.Polls(),
contentDescription = stringResource(id = CommonStrings.a11y_poll),
modifier = Modifier.size(22.dp)
)
}
Text(
text = title,
style = ElementTheme.typography.fontBodyLgMedium
)
}
}
@PreviewsDayNight
@Composable
internal fun PollTitleViewPreview() = ElementPreview {
PollTitleView(
title = "What is your favorite color?",
isPollEnded = false
)
}

View File

@@ -32,8 +32,8 @@ import io.element.android.features.poll.impl.history.model.PollHistoryFilter
import io.element.android.features.poll.impl.history.model.PollHistoryItems
import io.element.android.features.poll.impl.history.model.PollHistoryItemsFactory
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
@@ -44,11 +44,11 @@ class PollHistoryPresenter @Inject constructor(
private val sendPollResponseAction: SendPollResponseAction,
private val endPollAction: EndPollAction,
private val pollHistoryItemFactory: PollHistoryItemsFactory,
private val timelineProvider: TimelineProvider,
private val room: MatrixRoom,
) : Presenter<PollHistoryState> {
@Composable
override fun present(): PollHistoryState {
val timeline by timelineProvider.activeTimelineFlow().collectAsState()
val timeline = room.liveTimeline
val paginationState by timeline.paginationStatus(Timeline.PaginationDirection.BACKWARDS).collectAsState()
val pollHistoryItemsFlow = remember {
timeline.timelineItems.map { items ->

View File

@@ -38,7 +38,6 @@ import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.matrix.test.timeline.LiveTimelineProvider
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
@@ -180,7 +179,7 @@ class PollHistoryPresenterTest {
sendPollResponseAction = sendPollResponseAction,
endPollAction = endPollAction,
pollHistoryItemFactory = pollHistoryItemFactory,
timelineProvider = LiveTimelineProvider(room),
room = room,
)
}
}

View File

@@ -24,6 +24,7 @@ import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.architecture.NodeInputs
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.permalink.PermalinkData
import kotlinx.parcelize.Parcelize
interface RoomDetailsEntryPoint : FeatureEntryPoint {
@@ -43,6 +44,8 @@ interface RoomDetailsEntryPoint : FeatureEntryPoint {
interface Callback : Plugin {
fun onOpenGlobalNotificationSettings()
fun onOpenRoom(roomId: RoomId)
fun onPermalinkClick(data: PermalinkData, pushToBackstack: Boolean)
fun onForwardedToSingleRoom(roomId: RoomId)
}
interface NodeBuilder {

View File

@@ -61,6 +61,7 @@ dependencies {
implementation(projects.features.userprofile.shared)
implementation(projects.services.analytics.api)
implementation(projects.features.poll.api)
implementation(projects.features.messages.api)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)

View File

@@ -31,6 +31,7 @@ import im.vector.app.features.analytics.plan.Interaction
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.features.messages.api.MessagesEntryPoint
import io.element.android.features.poll.api.history.PollHistoryEntryPoint
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditNode
@@ -49,6 +50,7 @@ import io.element.android.libraries.di.RoomScope
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.media.MediaSource
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.mediaviewer.api.local.MediaInfo
import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode
@@ -64,6 +66,7 @@ class RoomDetailsFlowNode @AssistedInject constructor(
private val elementCallEntryPoint: ElementCallEntryPoint,
private val room: MatrixRoom,
private val analyticsService: AnalyticsService,
private val messagesEntryPoint: MessagesEntryPoint,
) : BaseFlowNode<RoomDetailsFlowNode.NavTarget>(
backstack = BackStack(
initialElement = plugins.filterIsInstance<RoomDetailsEntryPoint.Params>().first().initialElement.toNavTarget(),
@@ -105,6 +108,9 @@ class RoomDetailsFlowNode @AssistedInject constructor(
@Parcelize
data object AdminSettings : NavTarget
@Parcelize
data object PinnedMessagesList : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@@ -139,6 +145,10 @@ class RoomDetailsFlowNode @AssistedInject constructor(
backstack.push(NavTarget.AdminSettings)
}
override fun openPinnedMessagesList() {
backstack.push(NavTarget.PinnedMessagesList)
}
override fun onJoinCall() {
val inputs = CallType.RoomCall(
sessionId = room.sessionId,
@@ -224,6 +234,28 @@ class RoomDetailsFlowNode @AssistedInject constructor(
is NavTarget.AdminSettings -> {
createNode<RolesAndPermissionsFlowNode>(buildContext)
}
NavTarget.PinnedMessagesList -> {
val params = MessagesEntryPoint.Params(
MessagesEntryPoint.InitialTarget.PinnedMessages
)
val callback = object : MessagesEntryPoint.Callback {
override fun onRoomDetailsClick() = Unit
override fun onUserDataClick(userId: UserId) = Unit
override fun onPermalinkClick(data: PermalinkData, pushToBackstack: Boolean) {
plugins<RoomDetailsEntryPoint.Callback>().forEach { it.onPermalinkClick(data, pushToBackstack) }
}
override fun onForwardedToSingleRoom(roomId: RoomId) {
plugins<RoomDetailsEntryPoint.Callback>().forEach { it.onForwardedToSingleRoom(roomId) }
}
}
return messagesEntryPoint.nodeBuilder(this, buildContext)
.params(params)
.callback(callback)
.build()
}
}
}

View File

@@ -55,6 +55,7 @@ class RoomDetailsNode @AssistedInject constructor(
fun openAvatarPreview(name: String, url: String)
fun openPollHistory()
fun openAdminSettings()
fun openPinnedMessagesList()
fun onJoinCall()
}
@@ -115,6 +116,10 @@ class RoomDetailsNode @AssistedInject constructor(
callbacks.forEach { it.openAdminSettings() }
}
private fun openPinnedMessages() {
callbacks.forEach { it.openPinnedMessagesList() }
}
@Composable
override fun View(modifier: Modifier) {
val context = LocalContext.current
@@ -144,6 +149,7 @@ class RoomDetailsNode @AssistedInject constructor(
openPollHistory = ::openPollHistory,
openAdminSettings = this::openAdminSettings,
onJoinCallClick = ::onJoinCall,
onPinnedMessagesClick = ::openPinnedMessages
)
}
}

View File

@@ -29,6 +29,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import im.vector.app.features.analytics.plan.Interaction
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomPresenter
import io.element.android.features.messages.api.pinned.IsPinnedMessagesFeatureEnabled
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.bool.orFalse
@@ -67,6 +68,7 @@ class RoomDetailsPresenter @Inject constructor(
private val leaveRoomPresenter: LeaveRoomPresenter,
private val dispatchers: CoroutineDispatchers,
private val analyticsService: AnalyticsService,
private val isPinnedMessagesFeatureEnabled: IsPinnedMessagesFeatureEnabled,
) : Presenter<RoomDetailsState> {
@Composable
override fun present(): RoomDetailsState {
@@ -83,6 +85,9 @@ class RoomDetailsPresenter @Inject constructor(
val isFavorite by remember { derivedStateOf { roomInfo?.isFavorite.orFalse() } }
val isPublic by remember { derivedStateOf { roomInfo?.isPublic.orFalse() } }
val canShowPinnedMessages = isPinnedMessagesFeatureEnabled()
val pinnedMessagesCount by remember { derivedStateOf { roomInfo?.pinnedEventIds?.size } }
LaunchedEffect(Unit) {
canShowNotificationSettings.value = featureFlagService.isFeatureEnabled(FeatureFlags.NotificationSettings)
if (canShowNotificationSettings.value) {
@@ -156,6 +161,8 @@ class RoomDetailsPresenter @Inject constructor(
displayRolesAndPermissionsSettings = !room.isDm && isUserAdmin,
isPublic = isPublic,
heroes = roomInfo?.heroes.orEmpty().toPersistentList(),
canShowPinnedMessages = canShowPinnedMessages,
pinnedMessagesCount = pinnedMessagesCount,
eventSink = ::handleEvents,
)
}

View File

@@ -46,6 +46,8 @@ data class RoomDetailsState(
val displayRolesAndPermissionsSettings: Boolean,
val isPublic: Boolean,
val heroes: ImmutableList<MatrixUser>,
val canShowPinnedMessages: Boolean,
val pinnedMessagesCount: Int?,
val eventSink: (RoomDetailsEvent) -> Unit
)

View File

@@ -53,6 +53,7 @@ open class RoomDetailsStateProvider : PreviewParameterProvider<RoomDetailsState>
aRoomDetailsState(canCall = false, canInvite = false),
aRoomDetailsState(isPublic = false),
aRoomDetailsState(heroes = aMatrixUserList()),
aRoomDetailsState(pinnedMessagesCount = 3),
// Add other state here
)
}
@@ -105,6 +106,8 @@ fun aRoomDetailsState(
displayAdminSettings: Boolean = false,
isPublic: Boolean = true,
heroes: List<MatrixUser> = emptyList(),
canShowPinnedMessages: Boolean = true,
pinnedMessagesCount: Int? = null,
eventSink: (RoomDetailsEvent) -> Unit = {},
) = RoomDetailsState(
roomId = roomId,
@@ -126,6 +129,8 @@ fun aRoomDetailsState(
displayRolesAndPermissionsSettings = displayAdminSettings,
isPublic = isPublic,
heroes = heroes.toPersistentList(),
canShowPinnedMessages = canShowPinnedMessages,
pinnedMessagesCount = pinnedMessagesCount,
eventSink = eventSink
)

View File

@@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
@@ -66,6 +67,7 @@ import io.element.android.libraries.designsystem.components.preferences.Preferen
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.DropdownMenu
import io.element.android.libraries.designsystem.theme.components.DropdownMenuItem
import io.element.android.libraries.designsystem.theme.components.Icon
@@ -103,6 +105,7 @@ fun RoomDetailsView(
openPollHistory: () -> Unit,
openAdminSettings: () -> Unit,
onJoinCallClick: () -> Unit,
onPinnedMessagesClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
@@ -183,6 +186,13 @@ fun RoomDetailsView(
}
)
if (state.canShowPinnedMessages) {
PinnedMessagesItem(
pinnedMessagesCount = state.pinnedMessagesCount,
onPinnedMessagesClick = onPinnedMessagesClick
)
}
if (state.displayRolesAndPermissionsSettings) {
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_details_roles_and_permissions)) },
@@ -503,6 +513,26 @@ private fun MembersItem(
)
}
@Composable
private fun PinnedMessagesItem(
pinnedMessagesCount: Int?,
onPinnedMessagesClick: () -> Unit,
) {
ListItem(
headlineContent = { Text(stringResource(CommonStrings.screen_room_details_pinned_events_row_title)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Pin())),
trailingContent =
if (pinnedMessagesCount == null) {
ListItemContent.Custom {
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(24.dp))
}
} else {
ListItemContent.Text(pinnedMessagesCount.toString())
},
onClick = onPinnedMessagesClick,
)
}
@Composable
private fun PollsSection(
openPollHistory: () -> Unit,
@@ -573,5 +603,6 @@ private fun ContentToPreview(state: RoomDetailsState) {
openPollHistory = {},
openAdminSettings = {},
onJoinCallClick = {},
onPinnedMessagesClick = {},
)
}

View File

@@ -42,6 +42,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient
@@ -81,6 +82,7 @@ class RoomDetailsPresenterTest {
dispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(),
analyticsService: AnalyticsService = FakeAnalyticsService(),
isPinnedMessagesFeatureEnabled: Boolean = true,
): RoomDetailsPresenter {
val matrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService)
val roomMemberDetailsPresenterFactory = object : RoomMemberDetailsPresenter.Factory {
@@ -99,6 +101,7 @@ class RoomDetailsPresenterTest {
roomMembersDetailsPresenterFactory = roomMemberDetailsPresenterFactory,
leaveRoomPresenter = leaveRoomPresenter,
dispatchers = dispatchers,
isPinnedMessagesFeatureEnabled = { isPinnedMessagesFeatureEnabled },
analyticsService = analyticsService,
)
}
@@ -127,14 +130,15 @@ class RoomDetailsPresenterTest {
assertThat(initialState.roomTopic).isEqualTo(RoomTopicState.ExistingTopic(room.topic!!))
assertThat(initialState.memberCount).isEqualTo(room.joinedMemberCount)
assertThat(initialState.isEncrypted).isEqualTo(room.isEncrypted)
assertThat(initialState.canShowPinnedMessages).isTrue()
assertThat(initialState.pinnedMessagesCount).isNull()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - initial state is updated with roomInfo if it exists`() = runTest {
val roomInfo = aRoomInfo(name = "A room name", topic = "A topic", avatarUrl = "https://matrix.org/avatar.jpg")
val roomInfo = aRoomInfo(name = "A room name", topic = "A topic", avatarUrl = "https://matrix.org/avatar.jpg", pinnedEventIds = listOf(AN_EVENT_ID))
val room = aMatrixRoom(
canInviteResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
@@ -149,7 +153,7 @@ class RoomDetailsPresenterTest {
assertThat(updatedState.roomName).isEqualTo(roomInfo.name)
assertThat(updatedState.roomAvatarUrl).isEqualTo(roomInfo.avatarUrl)
assertThat(updatedState.roomTopic).isEqualTo(RoomTopicState.ExistingTopic(roomInfo.topic!!))
assertThat(updatedState.pinnedMessagesCount).isEqualTo(roomInfo.pinnedEventIds.size)
cancelAndIgnoreRemainingEvents()
}
}

View File

@@ -127,6 +127,21 @@ class RoomDetailsViewTest {
}
}
@Config(qualifiers = "h1024dp")
@Test
fun `click on pinned messages invokes expected callback`() {
ensureCalledOnce { callback ->
rule.setRoomDetailView(
state = aRoomDetailsState(
eventSink = EventsRecorder(expectEvents = false),
canInvite = true,
),
onPinnedMessagesClick = callback,
)
rule.clickOn(CommonStrings.screen_room_details_pinned_events_row_title)
}
}
@Config(qualifiers = "h1024dp")
@Test
fun `click on add topic emit expected event`() {
@@ -263,6 +278,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomD
openPollHistory: () -> Unit = EnsureNeverCalled(),
openAdminSettings: () -> Unit = EnsureNeverCalled(),
onJoinCallClick: () -> Unit = EnsureNeverCalled(),
onPinnedMessagesClick: () -> Unit = EnsureNeverCalled(),
) {
setContent {
RoomDetailsView(
@@ -277,6 +293,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomD
openPollHistory = openPollHistory,
openAdminSettings = openAdminSettings,
onJoinCallClick = onJoinCallClick,
onPinnedMessagesClick = onPinnedMessagesClick,
)
}
}

View File

@@ -0,0 +1,54 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.core.coroutine
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
/**
* A [StateFlow] that derives its value from a [Flow].
* Useful when you want to apply transformations to a [Flow] and expose it as a [StateFlow].
*/
class DerivedStateFlow<T>(
private val getValue: () -> T,
private val flow: Flow<T>
) : StateFlow<T> {
override val replayCache: List<T>
get() = listOf(value)
override val value: T
get() = getValue()
override suspend fun collect(collector: FlowCollector<T>): Nothing {
coroutineScope { flow.distinctUntilChanged().stateIn(this).collect(collector) }
}
}
/**
* Maps the value of a [StateFlow] to a new value and returns a new [StateFlow] with the mapped value.
*/
fun <T1, R> StateFlow<T1>.mapState(transform: (a: T1) -> R): StateFlow<R> {
return DerivedStateFlow(
getValue = { transform(this.value) },
flow = this.map { a -> transform(a) }
)
}

View File

@@ -42,6 +42,7 @@ dependencies {
implementation(libs.serialization.json)
api(projects.libraries.sessionStorage.api)
implementation(libs.coroutines.core)
api(projects.libraries.architecture)
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)

View File

@@ -47,6 +47,12 @@ interface Timeline : AutoCloseable {
FORWARDS
}
enum class Mode {
LIVE,
FOCUSED_ON_EVENT,
PINNED_EVENTS
}
val membershipChangeEventReceived: Flow<Unit>
suspend fun sendReadReceipt(eventId: EventId, receiptType: ReceiptType): Result<Unit>
suspend fun paginate(direction: PaginationDirection): Result<Boolean>

View File

@@ -17,14 +17,16 @@
package io.element.android.libraries.matrix.api.timeline
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
/**
* This interface defines a way to get the active timeline.
* It could be the current room timeline, or a timeline for a specific event.
* It could be the live timeline, a pinned timeline or a detached timeline.
* By default, the active timeline is the live timeline.
*/
interface TimelineProvider {
fun activeTimelineFlow(): StateFlow<Timeline>
fun activeTimelineFlow(): StateFlow<Timeline?>
}
suspend fun TimelineProvider.getActiveTimeline(): Timeline = activeTimelineFlow().first()
suspend fun TimelineProvider.getActiveTimeline(): Timeline = activeTimelineFlow().filterNotNull().first()

View File

@@ -154,7 +154,7 @@ class RustMatrixRoom(
private val _roomNotificationSettingsStateFlow = MutableStateFlow<MatrixRoomNotificationSettingsState>(MatrixRoomNotificationSettingsState.Unknown)
override val roomNotificationSettingsStateFlow: StateFlow<MatrixRoomNotificationSettingsState> = _roomNotificationSettingsStateFlow
override val liveTimeline = createTimeline(innerTimeline, isLive = true) {
override val liveTimeline = createTimeline(innerTimeline, mode = Timeline.Mode.LIVE) {
_syncUpdateFlow.value = systemClock.epochMillis()
}
@@ -182,7 +182,7 @@ class RustMatrixRoom(
numContextEvents = 50u,
internalIdPrefix = "focus_$eventId",
).let { inner ->
createTimeline(inner, isLive = false)
createTimeline(inner, mode = Timeline.Mode.FOCUSED_ON_EVENT)
}
}.mapFailure {
it.toFocusEventException()
@@ -199,7 +199,7 @@ class RustMatrixRoom(
internalIdPrefix = "pinned_events",
maxEventsToLoad = 100u,
).let { inner ->
createTimeline(inner, isLive = false)
createTimeline(inner, mode = Timeline.Mode.PINNED_EVENTS)
}
}.onFailure {
if (it is CancellationException) {
@@ -656,13 +656,13 @@ class RustMatrixRoom(
private fun createTimeline(
timeline: InnerTimeline,
isLive: Boolean,
mode: Timeline.Mode,
onNewSyncedEvent: () -> Unit = {},
): Timeline {
val timelineCoroutineScope = roomCoroutineScope.childScope(coroutineDispatchers.main, "TimelineScope-$roomId-$timeline")
return RustTimeline(
isKeyBackupEnabled = isKeyBackupEnabled,
isLive = isLive,
mode = mode,
matrixRoom = this,
systemClock = systemClock,
coroutineScope = timelineCoroutineScope,

View File

@@ -86,7 +86,7 @@ private const val PAGINATION_SIZE = 50
class RustTimeline(
private val inner: InnerTimeline,
private val isLive: Boolean,
mode: Timeline.Mode,
systemClock: SystemClock,
isKeyBackupEnabled: Boolean,
private val matrixRoom: MatrixRoom,
@@ -132,21 +132,21 @@ class RustTimeline(
onNewSyncedEvent = onNewSyncedEvent,
)
private val roomBeginningPostProcessor = RoomBeginningPostProcessor()
private val roomBeginningPostProcessor = RoomBeginningPostProcessor(mode)
private val loadingIndicatorsPostProcessor = LoadingIndicatorsPostProcessor(systemClock)
private val lastForwardIndicatorsPostProcessor = LastForwardIndicatorsPostProcessor(isLive)
private val lastForwardIndicatorsPostProcessor = LastForwardIndicatorsPostProcessor(mode)
private val backPaginationStatus = MutableStateFlow(
Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = true)
Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = mode != Timeline.Mode.PINNED_EVENTS)
)
private val forwardPaginationStatus = MutableStateFlow(
Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = !isLive)
Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = mode == Timeline.Mode.FOCUSED_ON_EVENT)
)
init {
coroutineScope.fetchMembers()
if (isLive) {
if (mode == Timeline.Mode.LIVE) {
// When timeline is live, we need to listen to the back pagination status as
// sdk can automatically paginate backwards.
coroutineScope.registerBackPaginationStatusListener()

View File

@@ -18,21 +18,22 @@ package io.element.android.libraries.matrix.impl.timeline.postprocessor
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
/**
* This post processor is responsible for adding virtual items to indicate all the previous last forward item.
*/
class LastForwardIndicatorsPostProcessor(
private val isTimelineLive: Boolean,
private val mode: Timeline.Mode,
) {
private val lastForwardIdentifiers = LinkedHashSet<UniqueId>()
fun process(
items: List<MatrixTimelineItem>,
): List<MatrixTimelineItem> {
// If the timeline is live, we don't have any last forward indicator to display
if (isTimelineLive) {
// We don't need to add the last forward indicator if we are not in the FOCUSED_ON_EVENT mode
if (mode != Timeline.Mode.FOCUSED_ON_EVENT) {
return items
} else {
return buildList {

View File

@@ -19,6 +19,7 @@ package io.element.android.libraries.matrix.impl.timeline.postprocessor
import androidx.annotation.VisibleForTesting
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
import io.element.android.libraries.matrix.api.timeline.item.event.OtherState
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
@@ -29,13 +30,14 @@ import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTime
* This timeline post-processor removes the room creation event and the self-join event from the timeline for DMs
* or add the RoomBeginning item for non DM room.
*/
class RoomBeginningPostProcessor {
class RoomBeginningPostProcessor(private val mode: Timeline.Mode) {
fun process(
items: List<MatrixTimelineItem>,
isDm: Boolean,
hasMoreToLoadBackwards: Boolean
): List<MatrixTimelineItem> {
return when {
mode == Timeline.Mode.PINNED_EVENTS -> items
hasMoreToLoadBackwards -> items
isDm -> processForDM(items)
else -> processForRoom(items)

View File

@@ -19,6 +19,7 @@ package io.element.android.libraries.matrix.impl.timeline.postprocessor
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
import io.element.android.libraries.matrix.api.timeline.item.event.OtherState
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
@@ -37,7 +38,7 @@ class RoomBeginningPostProcessorTest {
MatrixTimelineItem.Event(UniqueId("m.room.create"), anEventTimelineItem(sender = A_USER_ID, content = StateContent("", OtherState.RoomCreate))),
MatrixTimelineItem.Event(UniqueId("m.room.member"), anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, null, MembershipChange.JOINED))),
)
val processor = RoomBeginningPostProcessor()
val processor = RoomBeginningPostProcessor(Timeline.Mode.LIVE)
val processedItems = processor.process(timelineItems, isDm = true, hasMoreToLoadBackwards = false)
assertThat(processedItems).isEmpty()
}
@@ -60,7 +61,7 @@ class RoomBeginningPostProcessorTest {
),
MatrixTimelineItem.Event(UniqueId("m.room.message"), anEventTimelineItem(content = aMessageContent("hi"))),
)
val processor = RoomBeginningPostProcessor()
val processor = RoomBeginningPostProcessor(Timeline.Mode.LIVE)
val processedItems = processor.process(timelineItems, isDm = true, hasMoreToLoadBackwards = false)
assertThat(processedItems).isEqualTo(expected)
}
@@ -71,7 +72,7 @@ class RoomBeginningPostProcessorTest {
MatrixTimelineItem.Event(UniqueId("m.room.create"), anEventTimelineItem(sender = A_USER_ID, content = StateContent("", OtherState.RoomCreate))),
MatrixTimelineItem.Event(UniqueId("m.room.member"), anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, null, MembershipChange.JOINED))),
)
val processor = RoomBeginningPostProcessor()
val processor = RoomBeginningPostProcessor(Timeline.Mode.LIVE)
val processedItems = processor.process(timelineItems, isDm = false, hasMoreToLoadBackwards = false)
assertThat(processedItems).isEqualTo(
listOf(processor.createRoomBeginningItem()) + timelineItems
@@ -83,7 +84,7 @@ class RoomBeginningPostProcessorTest {
val timelineItems = listOf(
MatrixTimelineItem.Virtual(UniqueId("EncryptedHistoryBanner"), VirtualTimelineItem.EncryptedHistoryBanner),
)
val processor = RoomBeginningPostProcessor()
val processor = RoomBeginningPostProcessor(Timeline.Mode.LIVE)
val processedItems = processor.process(timelineItems, isDm = false, hasMoreToLoadBackwards = false)
assertThat(processedItems).isEqualTo(timelineItems)
}
@@ -94,7 +95,7 @@ class RoomBeginningPostProcessorTest {
MatrixTimelineItem.Event(UniqueId("m.room.create"), anEventTimelineItem(sender = A_USER_ID, content = StateContent("", OtherState.RoomCreate))),
MatrixTimelineItem.Event(UniqueId("m.room.member"), anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, null, MembershipChange.JOINED))),
)
val processor = RoomBeginningPostProcessor()
val processor = RoomBeginningPostProcessor(Timeline.Mode.LIVE)
val processedItems = processor.process(timelineItems, isDm = true, hasMoreToLoadBackwards = true)
assertThat(processedItems).isEqualTo(timelineItems)
}
@@ -104,7 +105,7 @@ class RoomBeginningPostProcessorTest {
val timelineItems = listOf(
MatrixTimelineItem.Event(UniqueId("m.room.member"), anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, null, MembershipChange.JOINED))),
)
val processor = RoomBeginningPostProcessor()
val processor = RoomBeginningPostProcessor(Timeline.Mode.LIVE)
val processedItems = processor.process(timelineItems, isDm = true, hasMoreToLoadBackwards = true)
assertThat(processedItems).isEqualTo(timelineItems)
}
@@ -118,7 +119,7 @@ class RoomBeginningPostProcessorTest {
anEventTimelineItem(content = RoomMembershipContent(A_USER_ID_2, null, MembershipChange.JOINED))
),
)
val processor = RoomBeginningPostProcessor()
val processor = RoomBeginningPostProcessor(Timeline.Mode.LIVE)
val processedItems = processor.process(timelineItems, isDm = true, hasMoreToLoadBackwards = true)
assertThat(processedItems).isEqualTo(timelineItems)
}