Merge pull request #3392 from element-hq/feature/fga/pinned_messages_list
[Feature] Pinned messages list
This commit is contained in:
commit
b802a196fc
98 changed files with 2279 additions and 357 deletions
|
|
@ -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 -> {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
|
||||
plugins {
|
||||
id("io.element.android-compose-library")
|
||||
id("kotlin-parcelize")
|
||||
}
|
||||
|
||||
android {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,15 +136,14 @@ class ActionListPresenter @Inject constructor(
|
|||
target.value = ActionListState.Target.None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildActions(
|
||||
private fun buildActions(
|
||||
timelineItem: TimelineItem.Event,
|
||||
usersEventPermissions: UserEventPermissions,
|
||||
isDeveloperModeEnabled: Boolean,
|
||||
isPinnedEventsEnabled: Boolean,
|
||||
isEventPinned: Boolean,
|
||||
): List<TimelineItemAction> {
|
||||
): List<TimelineItemAction> {
|
||||
val canRedact = timelineItem.isMine && usersEventPermissions.canRedactOwn || !timelineItem.isMine && usersEventPermissions.canRedactOther
|
||||
return buildList {
|
||||
if (timelineItem.canBeRepliedTo && usersEventPermissions.canSendMessage) {
|
||||
|
|
@ -167,7 +185,10 @@ private fun buildActions(
|
|||
if (canRedact) {
|
||||
add(TimelineItemAction.Redact)
|
||||
}
|
||||
}.postFilter(timelineItem.content)
|
||||
}
|
||||
.postFilter(timelineItem.content)
|
||||
.let(postProcessor::process)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:da5fed3d33c60f471b19fcf9761c5feb1e8fff303d84e951ecd53e8cd54613d0
|
||||
size 19938
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:fea6dbb47a656e0d39181a40b32277e17f0fe52c99e8c1519d5f09ff9423e98c
|
||||
size 8983
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:54a495cd353317e667d667d3f98c4b844568b0375d83b7cb4091bafd30dec194
|
||||
size 31738
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6fee1e714cc759547034e8e688e69ee525336e18febbbc118b7e1fdabe159a60
|
||||
size 42698
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:93fe898626703045ac2efc2c5c9c2a404a9fa446333649ab77cf1acd7da4f1e7
|
||||
size 18057
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f221c0af78be8d43a481ff0b122ea0587e0e0618c6f6fec78cb91368e940f701
|
||||
size 8720
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:97ae5c5a01f94e35d2d05d3b1a6f3be992fcf49f9674bdf913753c5430d3e0bf
|
||||
size 30749
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7a16fcbc220880ccd0280e734bcdb6184b5a34a7505708f8f7795b6431cf6f41
|
||||
size 41511
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:73d64e6f6fd0f979c1929f3f1118d26890e55c301031dd3ca0f64647429dd42c
|
||||
size 9358
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e5b23e028c2df95e19ee96b0715b3da7f32ea15d7b50a6bd6068add0263c0c2a
|
||||
size 9081
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d2e96d71fd2e0e67415ab5d69b2712661f4e453980e202e60717cc73e1e3fa81
|
||||
size 45821
|
||||
oid sha256:8cef0a991d7b6a66dac0687e278231dcd4c9084e3ea04fc5e28d7d15f8b30795
|
||||
size 40901
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1884020edf5182e4be0ef7408ae09d3ebdc79e4f482c0ed22c47864fb4ca3b6d
|
||||
size 43682
|
||||
oid sha256:1481831145c4a32c046f63748a1ac2eccfe8ff954b1926b22115110e9f4c2e44
|
||||
size 45041
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:50940f08a015ca4ecbd4f88810b183d1632daa34e8f79c38017f20ad5a1ce90a
|
||||
size 42637
|
||||
oid sha256:75d9e78aec05eacb67872f036490d2dbed6174e160bfc1f983123e043c00d056
|
||||
size 44003
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:259aced4d75ec473abf8f4fb41c3c734e69618a552b2cf0b9ceeb98d59deec25
|
||||
size 45717
|
||||
oid sha256:0a6a3a952c2b2e5304cd83674fb209d11f07a366375aec36cc4e4f981a541b03
|
||||
size 47082
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c2ed5843b61db2188b6673a07f899966ed041cd5acfe12dcf40539d96e77eaab
|
||||
size 45455
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4885f85102e8e8a509586fb48e7a26d5ceb1dd7aa6897b955cb781eed87b9f30
|
||||
size 35023
|
||||
oid sha256:85e674e93c73ddbe77c71bfa8ca6f117b9f0d6863fa5137f12f0e9ed62a6e4b7
|
||||
size 37414
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f6d8d1c7aa92f526398221aeff77cc44ebdad36bc4a1259ebb3c9635483af1f5
|
||||
size 37117
|
||||
oid sha256:de1debdc3ff7a00a7de3b7b381a5bd1102016d39e072af2325c4ffb40988d054
|
||||
size 38281
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3679bf87660a8bd799ae9c4397266227a0f46559bdcae6ddecec0930d5c78e95
|
||||
size 35993
|
||||
oid sha256:86a5148af36166fd0cb8fb022b85645e64f0cccb9760848d75af4927f715cdc6
|
||||
size 38413
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0ae27c470cb9be1fa75b85b607bcc3666839801376e00b9c0ec8b86d229d907b
|
||||
size 43236
|
||||
oid sha256:9893ea709177f3f0101bfaed241de44e85d179d99f133f6d4057a251b88c3a9d
|
||||
size 44234
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ea6ba2ec613561a26331c72ca89a6bd44b96c160bee75bc9a9b9a06339426275
|
||||
size 41660
|
||||
oid sha256:c1ca7803057968031f909b1311b023bfb9ae1978003c8beb80a85d065968bd23
|
||||
size 42324
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ea6ba2ec613561a26331c72ca89a6bd44b96c160bee75bc9a9b9a06339426275
|
||||
size 41660
|
||||
oid sha256:c1ca7803057968031f909b1311b023bfb9ae1978003c8beb80a85d065968bd23
|
||||
size 42324
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:bf9ea4e85c5dc2d9d5c609b29e6232a5932c9b3bee81c459e9214ac16f7a3764
|
||||
size 44986
|
||||
oid sha256:cfcee02543f55d395112fd662d785ef553260a4457b5ab753b6198c0c13a2068
|
||||
size 46345
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:569a0dfdc4c5c77f44999b0e9e7a1b2458cac6b01dfd56800d3e7c3b5f8c0347
|
||||
size 43930
|
||||
oid sha256:50023e5cd9260b3db79fa1ab15489e165dc7df9663584317d0fe2243f1622ea2
|
||||
size 45288
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:45e0e9952c3ca0fb805b5e53b990c3ec54732a971ecc658e25ef73254b3f1ca4
|
||||
size 43871
|
||||
oid sha256:487ecdc6e7bbb2389e8f21a99b348939f371c6fa8fc090fcff46f5a51e2390cb
|
||||
size 45230
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:dcf1f93aa20a0acd4c32f761fdd39abf46016c84072f0a22c9b9cf0dd7f2c6f2
|
||||
size 46788
|
||||
oid sha256:38f6a49907dfbafdeb18184ea70315882f26729353975748be33246dd06de810
|
||||
size 41776
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:dc8fb59e77e8e09e79f5d89e039f302f28e2451faa025e9f1ce3b0ac48f75f8d
|
||||
size 44404
|
||||
oid sha256:13189861bd0b613aabe126a7064277695f9e0a31ce6e7d0104c1b786900d855c
|
||||
size 45869
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e3166a62b24bfaf55d3dfa5d6e9fcd6dca2ee2789928efd66d8147e4e11d0ce0
|
||||
size 43377
|
||||
oid sha256:f6231ddd26d71291a3acfe347ccaa5b5213809969d3a21667f5c872da145bbe6
|
||||
size 44844
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f799d30b5c3d847cc8011ce4aefbb8710b34df8f11116842af035b05d233cfa1
|
||||
size 46485
|
||||
oid sha256:f4841522e54ab49cf7f2ac4e06223addf050c4a0f9c7a3e351d73fbd0fbb62b9
|
||||
size 47959
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f346764da31583076508550c339be523d15f338b00f2f412e1b117bd1e3b5062
|
||||
size 46333
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:09ce1743e0e3c5629fab6d0c2d06cc15be7229e446673b05f49f0e031a1c2823
|
||||
size 35776
|
||||
oid sha256:17a68abaa010d037c00b3ae24ab470c9cf2e7b42d1c45391819b5e46e3b3c2c7
|
||||
size 38265
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:84ef09f2163f10a583853a52008f5a53f26c90350136a970fd55e0199f5fd47f
|
||||
size 37907
|
||||
oid sha256:3d67989cc90311a6810882f1cc9f63d4849c9768892403c6366f260b6f01782a
|
||||
size 39097
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9941f55b35128067bd7560ed24d44bc0bbe8e13a6702d6ec2d35304462f755a2
|
||||
size 36503
|
||||
oid sha256:777cecce6d855418ade591c3f02d97dbc82bc81f3317bc64cc76ae8f0e94a792
|
||||
size 39004
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b300a88051346537beac5096895ef87b007e56fdddc33ad63db092f4c813a454
|
||||
size 44041
|
||||
oid sha256:2fb04b9ecf34bd1f9218b137ad213a385abd03b0a4767f7c88ef04ee496f8e4c
|
||||
size 45084
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0fe90dffd26521d58e0956b9ea967c641f75a7cdeb39052abbfcdf85754c5dd3
|
||||
size 42281
|
||||
oid sha256:31eba128b859c1a5b4da1f62de323d29f62a36dd962877fbf3fdea0a51b81fde
|
||||
size 42948
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0fe90dffd26521d58e0956b9ea967c641f75a7cdeb39052abbfcdf85754c5dd3
|
||||
size 42281
|
||||
oid sha256:31eba128b859c1a5b4da1f62de323d29f62a36dd962877fbf3fdea0a51b81fde
|
||||
size 42948
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:41b3d79ae55b26478c2e6d1910b37a98eb22ac37e9bfd3a33de4dc805e82028b
|
||||
size 45850
|
||||
oid sha256:47260328f01cc55a7825ab8bebe540aac41efcee77cc52a0d82deb3fb1e496b3
|
||||
size 47321
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:02a966757cc01f8707738d3cd29d05df0bf3842069fec812c7223e2e29da5dbf
|
||||
size 44761
|
||||
oid sha256:9cb0fb71d0e3433bd9512d435f47c3f116627202d1f81debd5f3c43eece64a5d
|
||||
size 46223
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5a2148c28660bd9fd3a45b1f06b331966f79c77166b40bb1b7afb5b926407817
|
||||
size 44647
|
||||
oid sha256:6939bdfecb6177a7c180bf94d18d80aea0ebceb16f06c87d0cc598068bc73765
|
||||
size 46114
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue