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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -23,8 +23,14 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope 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.UserEventPermissions
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction 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.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemCallNotifyContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemCallNotifyContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.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.canBeForwarded
import io.element.android.features.messages.impl.timeline.model.event.canReact import io.element.android.features.messages.impl.timeline.model.event.canReact
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.preferences.api.store.AppPreferencesStore 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.CoroutineScope
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch 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 appPreferencesStore: AppPreferencesStore,
private val featureFlagsService: FeatureFlagService, private val isPinnedMessagesFeatureEnabled: IsPinnedMessagesFeatureEnabled,
private val room: MatrixRoom, private val room: MatrixRoom,
) : Presenter<ActionListState> { ) : ActionListPresenter {
@AssistedFactory
@ContributesBinding(RoomScope::class)
interface Factory : ActionListPresenter.Factory {
override fun create(postProcessor: TimelineItemActionPostProcessor): DefaultActionListPresenter
}
@Composable @Composable
override fun present(): ActionListState { override fun present(): ActionListState {
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
@ -63,7 +81,7 @@ class ActionListPresenter @Inject constructor(
} }
val isDeveloperModeEnabled by appPreferencesStore.isDeveloperModeEnabledFlow().collectAsState(initial = false) 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 { val pinnedEventIds by remember {
room.roomInfoFlow.map { it.pinnedEventIds } room.roomInfoFlow.map { it.pinnedEventIds }
}.collectAsState(initial = persistentListOf()) }.collectAsState(initial = persistentListOf())
@ -105,6 +123,7 @@ class ActionListPresenter @Inject constructor(
isPinnedEventsEnabled = isPinnedEventsEnabled, isPinnedEventsEnabled = isPinnedEventsEnabled,
isEventPinned = pinnedEventIds.contains(timelineItem.eventId), isEventPinned = pinnedEventIds.contains(timelineItem.eventId),
) )
val displayEmojiReactions = usersEventPermissions.canSendReaction && val displayEmojiReactions = usersEventPermissions.canSendReaction &&
timelineItem.content.canReact() timelineItem.content.canReact()
if (actions.isNotEmpty() || displayEmojiReactions) { if (actions.isNotEmpty() || displayEmojiReactions) {
@ -117,57 +136,59 @@ class ActionListPresenter @Inject constructor(
target.value = ActionListState.Target.None target.value = ActionListState.Target.None
} }
} }
}
private fun buildActions( private fun buildActions(
timelineItem: TimelineItem.Event, timelineItem: TimelineItem.Event,
usersEventPermissions: UserEventPermissions, usersEventPermissions: UserEventPermissions,
isDeveloperModeEnabled: Boolean, isDeveloperModeEnabled: Boolean,
isPinnedEventsEnabled: Boolean, isPinnedEventsEnabled: Boolean,
isEventPinned: Boolean, isEventPinned: Boolean,
): List<TimelineItemAction> { ): List<TimelineItemAction> {
val canRedact = timelineItem.isMine && usersEventPermissions.canRedactOwn || !timelineItem.isMine && usersEventPermissions.canRedactOther val canRedact = timelineItem.isMine && usersEventPermissions.canRedactOwn || !timelineItem.isMine && usersEventPermissions.canRedactOther
return buildList { return buildList {
if (timelineItem.canBeRepliedTo && usersEventPermissions.canSendMessage) { if (timelineItem.canBeRepliedTo && usersEventPermissions.canSendMessage) {
if (timelineItem.isThreaded) { if (timelineItem.isThreaded) {
add(TimelineItemAction.ReplyInThread) add(TimelineItemAction.ReplyInThread)
} else { } else {
add(TimelineItemAction.Reply) add(TimelineItemAction.Reply)
}
}
if (timelineItem.isRemote && timelineItem.content.canBeForwarded()) {
add(TimelineItemAction.Forward)
}
if (timelineItem.isEditable) {
add(TimelineItemAction.Edit)
}
if (canRedact && timelineItem.content is TimelineItemPollContent && !timelineItem.content.isEnded) {
add(TimelineItemAction.EndPoll)
}
val canPinUnpin = isPinnedEventsEnabled && usersEventPermissions.canPinUnpin && timelineItem.isRemote
if (canPinUnpin) {
if (isEventPinned) {
add(TimelineItemAction.Unpin)
} else {
add(TimelineItemAction.Pin)
}
}
if (timelineItem.content.canBeCopied()) {
add(TimelineItemAction.Copy)
}
if (timelineItem.isRemote) {
add(TimelineItemAction.CopyLink)
}
if (isDeveloperModeEnabled) {
add(TimelineItemAction.ViewSource)
}
if (!timelineItem.isMine) {
add(TimelineItemAction.ReportContent)
}
if (canRedact) {
add(TimelineItemAction.Redact)
} }
} }
if (timelineItem.isRemote && timelineItem.content.canBeForwarded()) { .postFilter(timelineItem.content)
add(TimelineItemAction.Forward) .let(postProcessor::process)
} }
if (timelineItem.isEditable) {
add(TimelineItemAction.Edit)
}
if (canRedact && timelineItem.content is TimelineItemPollContent && !timelineItem.content.isEnded) {
add(TimelineItemAction.EndPoll)
}
val canPinUnpin = isPinnedEventsEnabled && usersEventPermissions.canPinUnpin && timelineItem.isRemote
if (canPinUnpin) {
if (isEventPinned) {
add(TimelineItemAction.Unpin)
} else {
add(TimelineItemAction.Pin)
}
}
if (timelineItem.content.canBeCopied()) {
add(TimelineItemAction.Copy)
}
if (timelineItem.isRemote) {
add(TimelineItemAction.CopyLink)
}
if (isDeveloperModeEnabled) {
add(TimelineItemAction.ViewSource)
}
if (!timelineItem.isMine) {
add(TimelineItemAction.ReportContent)
}
if (canRedact) {
add(TimelineItemAction.Redact)
}
}.postFilter(timelineItem.content)
} }
/** /**

View file

@ -29,6 +29,7 @@ sealed class TimelineItemAction(
@DrawableRes val icon: Int, @DrawableRes val icon: Int,
val destructive: Boolean = false 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 Forward : TimelineItemAction(CommonStrings.action_forward, CompoundDrawables.ic_compound_forward)
data object Copy : TimelineItemAction(CommonStrings.action_copy, CompoundDrawables.ic_compound_copy) 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) data object CopyLink : TimelineItemAction(CommonStrings.action_copy_link_to_message, CompoundDrawables.ic_compound_link)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -19,10 +19,8 @@ package io.element.android.features.poll.api.pollcontent
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.runtime.Composable 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.semantics.Role
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme 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.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight 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.Button
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.poll.PollAnswer import io.element.android.libraries.matrix.api.poll.PollAnswer
@ -117,7 +113,7 @@ fun PollContentView(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
PollTitle(title = question, isPollEnded = isPollEnded) PollTitleView(title = question, isPollEnded = isPollEnded)
PollAnswers(answerItems = answerItems, onSelectAnswer = ::onSelectAnswer) 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 @Composable
private fun PollAnswers( private fun PollAnswers(
answerItems: ImmutableList<PollAnswerItem>, answerItems: ImmutableList<PollAnswerItem>,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons 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.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight 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.DropdownMenu
import io.element.android.libraries.designsystem.theme.components.DropdownMenuItem import io.element.android.libraries.designsystem.theme.components.DropdownMenuItem
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
@ -103,6 +105,7 @@ fun RoomDetailsView(
openPollHistory: () -> Unit, openPollHistory: () -> Unit,
openAdminSettings: () -> Unit, openAdminSettings: () -> Unit,
onJoinCallClick: () -> Unit, onJoinCallClick: () -> Unit,
onPinnedMessagesClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Scaffold( Scaffold(
@ -183,6 +186,13 @@ fun RoomDetailsView(
} }
) )
if (state.canShowPinnedMessages) {
PinnedMessagesItem(
pinnedMessagesCount = state.pinnedMessagesCount,
onPinnedMessagesClick = onPinnedMessagesClick
)
}
if (state.displayRolesAndPermissionsSettings) { if (state.displayRolesAndPermissionsSettings) {
ListItem( ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_details_roles_and_permissions)) }, 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 @Composable
private fun PollsSection( private fun PollsSection(
openPollHistory: () -> Unit, openPollHistory: () -> Unit,
@ -573,5 +603,6 @@ private fun ContentToPreview(state: RoomDetailsState) {
openPollHistory = {}, openPollHistory = {},
openAdminSettings = {}, openAdminSettings = {},
onJoinCallClick = {}, onJoinCallClick = {},
onPinnedMessagesClick = {},
) )
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -17,14 +17,16 @@
package io.element.android.libraries.matrix.api.timeline package io.element.android.libraries.matrix.api.timeline
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
/** /**
* This interface defines a way to get the active timeline. * 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 { interface TimelineProvider {
fun activeTimelineFlow(): StateFlow<Timeline> fun activeTimelineFlow(): StateFlow<Timeline?>
} }
suspend fun TimelineProvider.getActiveTimeline(): Timeline = activeTimelineFlow().first() suspend fun TimelineProvider.getActiveTimeline(): Timeline = activeTimelineFlow().filterNotNull().first()

View file

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

View file

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

View file

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

View file

@ -19,6 +19,7 @@ package io.element.android.libraries.matrix.impl.timeline.postprocessor
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import io.element.android.libraries.matrix.api.core.UniqueId 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.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.MembershipChange
import io.element.android.libraries.matrix.api.timeline.item.event.OtherState import io.element.android.libraries.matrix.api.timeline.item.event.OtherState
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent 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 * 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. * or add the RoomBeginning item for non DM room.
*/ */
class RoomBeginningPostProcessor { class RoomBeginningPostProcessor(private val mode: Timeline.Mode) {
fun process( fun process(
items: List<MatrixTimelineItem>, items: List<MatrixTimelineItem>,
isDm: Boolean, isDm: Boolean,
hasMoreToLoadBackwards: Boolean hasMoreToLoadBackwards: Boolean
): List<MatrixTimelineItem> { ): List<MatrixTimelineItem> {
return when { return when {
mode == Timeline.Mode.PINNED_EVENTS -> items
hasMoreToLoadBackwards -> items hasMoreToLoadBackwards -> items
isDm -> processForDM(items) isDm -> processForDM(items)
else -> processForRoom(items) else -> processForRoom(items)

View file

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

View file

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

View file

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

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:54a495cd353317e667d667d3f98c4b844568b0375d83b7cb4091bafd30dec194
size 31738

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6fee1e714cc759547034e8e688e69ee525336e18febbbc118b7e1fdabe159a60
size 42698

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:93fe898626703045ac2efc2c5c9c2a404a9fa446333649ab77cf1acd7da4f1e7
size 18057

View file

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

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:97ae5c5a01f94e35d2d05d3b1a6f3be992fcf49f9674bdf913753c5430d3e0bf
size 30749

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7a16fcbc220880ccd0280e734bcdb6184b5a34a7505708f8f7795b6431cf6f41
size 41511

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:73d64e6f6fd0f979c1929f3f1118d26890e55c301031dd3ca0f64647429dd42c
size 9358

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d2e96d71fd2e0e67415ab5d69b2712661f4e453980e202e60717cc73e1e3fa81 oid sha256:8cef0a991d7b6a66dac0687e278231dcd4c9084e3ea04fc5e28d7d15f8b30795
size 45821 size 40901

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:1884020edf5182e4be0ef7408ae09d3ebdc79e4f482c0ed22c47864fb4ca3b6d oid sha256:1481831145c4a32c046f63748a1ac2eccfe8ff954b1926b22115110e9f4c2e44
size 43682 size 45041

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:50940f08a015ca4ecbd4f88810b183d1632daa34e8f79c38017f20ad5a1ce90a oid sha256:75d9e78aec05eacb67872f036490d2dbed6174e160bfc1f983123e043c00d056
size 42637 size 44003

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:259aced4d75ec473abf8f4fb41c3c734e69618a552b2cf0b9ceeb98d59deec25 oid sha256:0a6a3a952c2b2e5304cd83674fb209d11f07a366375aec36cc4e4f981a541b03
size 45717 size 47082

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:4885f85102e8e8a509586fb48e7a26d5ceb1dd7aa6897b955cb781eed87b9f30 oid sha256:85e674e93c73ddbe77c71bfa8ca6f117b9f0d6863fa5137f12f0e9ed62a6e4b7
size 35023 size 37414

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f6d8d1c7aa92f526398221aeff77cc44ebdad36bc4a1259ebb3c9635483af1f5 oid sha256:de1debdc3ff7a00a7de3b7b381a5bd1102016d39e072af2325c4ffb40988d054
size 37117 size 38281

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:3679bf87660a8bd799ae9c4397266227a0f46559bdcae6ddecec0930d5c78e95 oid sha256:86a5148af36166fd0cb8fb022b85645e64f0cccb9760848d75af4927f715cdc6
size 35993 size 38413

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:0ae27c470cb9be1fa75b85b607bcc3666839801376e00b9c0ec8b86d229d907b oid sha256:9893ea709177f3f0101bfaed241de44e85d179d99f133f6d4057a251b88c3a9d
size 43236 size 44234

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:ea6ba2ec613561a26331c72ca89a6bd44b96c160bee75bc9a9b9a06339426275 oid sha256:c1ca7803057968031f909b1311b023bfb9ae1978003c8beb80a85d065968bd23
size 41660 size 42324

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:ea6ba2ec613561a26331c72ca89a6bd44b96c160bee75bc9a9b9a06339426275 oid sha256:c1ca7803057968031f909b1311b023bfb9ae1978003c8beb80a85d065968bd23
size 41660 size 42324

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:bf9ea4e85c5dc2d9d5c609b29e6232a5932c9b3bee81c459e9214ac16f7a3764 oid sha256:cfcee02543f55d395112fd662d785ef553260a4457b5ab753b6198c0c13a2068
size 44986 size 46345

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:569a0dfdc4c5c77f44999b0e9e7a1b2458cac6b01dfd56800d3e7c3b5f8c0347 oid sha256:50023e5cd9260b3db79fa1ab15489e165dc7df9663584317d0fe2243f1622ea2
size 43930 size 45288

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:45e0e9952c3ca0fb805b5e53b990c3ec54732a971ecc658e25ef73254b3f1ca4 oid sha256:487ecdc6e7bbb2389e8f21a99b348939f371c6fa8fc090fcff46f5a51e2390cb
size 43871 size 45230

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:dcf1f93aa20a0acd4c32f761fdd39abf46016c84072f0a22c9b9cf0dd7f2c6f2 oid sha256:38f6a49907dfbafdeb18184ea70315882f26729353975748be33246dd06de810
size 46788 size 41776

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:dc8fb59e77e8e09e79f5d89e039f302f28e2451faa025e9f1ce3b0ac48f75f8d oid sha256:13189861bd0b613aabe126a7064277695f9e0a31ce6e7d0104c1b786900d855c
size 44404 size 45869

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e3166a62b24bfaf55d3dfa5d6e9fcd6dca2ee2789928efd66d8147e4e11d0ce0 oid sha256:f6231ddd26d71291a3acfe347ccaa5b5213809969d3a21667f5c872da145bbe6
size 43377 size 44844

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f799d30b5c3d847cc8011ce4aefbb8710b34df8f11116842af035b05d233cfa1 oid sha256:f4841522e54ab49cf7f2ac4e06223addf050c4a0f9c7a3e351d73fbd0fbb62b9
size 46485 size 47959

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:09ce1743e0e3c5629fab6d0c2d06cc15be7229e446673b05f49f0e031a1c2823 oid sha256:17a68abaa010d037c00b3ae24ab470c9cf2e7b42d1c45391819b5e46e3b3c2c7
size 35776 size 38265

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:84ef09f2163f10a583853a52008f5a53f26c90350136a970fd55e0199f5fd47f oid sha256:3d67989cc90311a6810882f1cc9f63d4849c9768892403c6366f260b6f01782a
size 37907 size 39097

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9941f55b35128067bd7560ed24d44bc0bbe8e13a6702d6ec2d35304462f755a2 oid sha256:777cecce6d855418ade591c3f02d97dbc82bc81f3317bc64cc76ae8f0e94a792
size 36503 size 39004

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b300a88051346537beac5096895ef87b007e56fdddc33ad63db092f4c813a454 oid sha256:2fb04b9ecf34bd1f9218b137ad213a385abd03b0a4767f7c88ef04ee496f8e4c
size 44041 size 45084

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:0fe90dffd26521d58e0956b9ea967c641f75a7cdeb39052abbfcdf85754c5dd3 oid sha256:31eba128b859c1a5b4da1f62de323d29f62a36dd962877fbf3fdea0a51b81fde
size 42281 size 42948

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:0fe90dffd26521d58e0956b9ea967c641f75a7cdeb39052abbfcdf85754c5dd3 oid sha256:31eba128b859c1a5b4da1f62de323d29f62a36dd962877fbf3fdea0a51b81fde
size 42281 size 42948

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:41b3d79ae55b26478c2e6d1910b37a98eb22ac37e9bfd3a33de4dc805e82028b oid sha256:47260328f01cc55a7825ab8bebe540aac41efcee77cc52a0d82deb3fb1e496b3
size 45850 size 47321

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:02a966757cc01f8707738d3cd29d05df0bf3842069fec812c7223e2e29da5dbf oid sha256:9cb0fb71d0e3433bd9512d435f47c3f116627202d1f81debd5f3c43eece64a5d
size 44761 size 46223

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:5a2148c28660bd9fd3a45b1f06b331966f79c77166b40bb1b7afb5b926407817 oid sha256:6939bdfecb6177a7c180bf94d18d80aea0ebceb16f06c87d0cc598068bc73765
size 44647 size 46114