Merge pull request #3392 from element-hq/feature/fga/pinned_messages_list
[Feature] Pinned messages list
This commit is contained in:
commit
e5c81db224
98 changed files with 2279 additions and 357 deletions
|
|
@ -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 -> {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("io.element.android-compose-library")
|
id("io.element.android-compose-library")
|
||||||
|
id("kotlin-parcelize")
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2024 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.api.pinned
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
|
||||||
|
fun interface IsPinnedMessagesFeatureEnabled {
|
||||||
|
@Composable
|
||||||
|
operator fun invoke(): Boolean
|
||||||
|
}
|
||||||
|
|
@ -32,7 +32,7 @@ class DefaultMessagesEntryPoint @Inject constructor() : MessagesEntryPoint {
|
||||||
|
|
||||||
return object : MessagesEntryPoint.NodeBuilder {
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2024 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.actionlist.model
|
||||||
|
|
||||||
|
fun interface TimelineItemActionPostProcessor {
|
||||||
|
fun process(actions: List<TimelineItemAction>): List<TimelineItemAction>
|
||||||
|
|
||||||
|
object Default : TimelineItemActionPostProcessor {
|
||||||
|
override fun process(actions: List<TimelineItemAction>): List<TimelineItemAction> {
|
||||||
|
return actions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -34,6 +34,7 @@ import io.element.android.libraries.architecture.inputs
|
||||||
import io.element.android.libraries.di.RoomScope
|
import io.element.android.libraries.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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2024 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.pinned
|
||||||
|
|
||||||
|
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
||||||
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
|
import io.element.android.libraries.core.coroutine.mapState
|
||||||
|
import io.element.android.libraries.di.RoomScope
|
||||||
|
import io.element.android.libraries.di.SingleIn
|
||||||
|
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||||
|
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||||
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.onCompletion
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@SingleIn(RoomScope::class)
|
||||||
|
class PinnedEventsTimelineProvider @Inject constructor(
|
||||||
|
private val room: MatrixRoom,
|
||||||
|
private val networkMonitor: NetworkMonitor,
|
||||||
|
private val featureFlagService: FeatureFlagService,
|
||||||
|
) : TimelineProvider {
|
||||||
|
private val _timelineStateFlow: MutableStateFlow<AsyncData<Timeline>> = MutableStateFlow(AsyncData.Uninitialized)
|
||||||
|
|
||||||
|
override fun activeTimelineFlow(): StateFlow<Timeline?> {
|
||||||
|
return _timelineStateFlow
|
||||||
|
.mapState { value ->
|
||||||
|
value.dataOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val timelineStateFlow = _timelineStateFlow
|
||||||
|
|
||||||
|
fun launchIn(scope: CoroutineScope) {
|
||||||
|
combine(
|
||||||
|
featureFlagService.isFeatureEnabledFlow(FeatureFlags.PinnedEvents),
|
||||||
|
networkMonitor.connectivity
|
||||||
|
) {
|
||||||
|
// do not use connectivity here as data can be loaded from cache, it's just to trigger retry if needed
|
||||||
|
isEnabled, _ ->
|
||||||
|
isEnabled
|
||||||
|
}
|
||||||
|
.onEach { isFeatureEnabled ->
|
||||||
|
if (isFeatureEnabled) {
|
||||||
|
loadTimelineIfNeeded()
|
||||||
|
} else {
|
||||||
|
_timelineStateFlow.value = AsyncData.Uninitialized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onCompletion {
|
||||||
|
invokeOnTimeline { close() }
|
||||||
|
}
|
||||||
|
.launchIn(scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun invokeOnTimeline(action: suspend Timeline.() -> Unit) {
|
||||||
|
when (val asyncTimeline = timelineStateFlow.value) {
|
||||||
|
is AsyncData.Success -> action(asyncTimeline.data)
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadTimelineIfNeeded() {
|
||||||
|
when (timelineStateFlow.value) {
|
||||||
|
is AsyncData.Uninitialized, is AsyncData.Failure -> {
|
||||||
|
timelineStateFlow.emit(AsyncData.Loading())
|
||||||
|
room.pinnedEventsTimeline()
|
||||||
|
.fold(
|
||||||
|
{ timelineStateFlow.emit(AsyncData.Success(it)) },
|
||||||
|
{ timelineStateFlow.emit(AsyncData.Failure(it)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -26,18 +26,19 @@ import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2024 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.pinned.list
|
||||||
|
|
||||||
|
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
|
|
||||||
|
sealed interface PinnedMessagesListEvents {
|
||||||
|
data class HandleAction(val action: TimelineItemAction, val event: TimelineItem.Event) : PinnedMessagesListEvents
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2024 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.pinned.list
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||||
|
|
||||||
|
interface PinnedMessagesListNavigator {
|
||||||
|
fun onViewInTimelineClick(eventId: EventId)
|
||||||
|
fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo)
|
||||||
|
fun onForwardEventClick(eventId: EventId)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2024 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.pinned.list
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
|
import com.bumble.appyx.core.node.Node
|
||||||
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
|
import com.bumble.appyx.core.plugin.plugins
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
|
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
|
||||||
|
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
|
import io.element.android.libraries.androidutils.system.openUrlInExternalApp
|
||||||
|
import io.element.android.libraries.di.RoomScope
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||||
|
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||||
|
|
||||||
|
@ContributesNode(RoomScope::class)
|
||||||
|
class PinnedMessagesListNode @AssistedInject constructor(
|
||||||
|
@Assisted buildContext: BuildContext,
|
||||||
|
@Assisted plugins: List<Plugin>,
|
||||||
|
presenterFactory: PinnedMessagesListPresenter.Factory,
|
||||||
|
private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
|
||||||
|
private val permalinkParser: PermalinkParser,
|
||||||
|
) : Node(buildContext, plugins = plugins), PinnedMessagesListNavigator {
|
||||||
|
interface Callback : Plugin {
|
||||||
|
fun onEventClick(event: TimelineItem.Event)
|
||||||
|
fun onUserDataClick(userId: UserId)
|
||||||
|
fun onViewInTimelineClick(eventId: EventId)
|
||||||
|
fun onRoomPermalinkClick(data: PermalinkData.RoomLink)
|
||||||
|
fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo)
|
||||||
|
fun onForwardEventClick(eventId: EventId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val presenter = presenterFactory.create(this)
|
||||||
|
private val callbacks = plugins<Callback>()
|
||||||
|
|
||||||
|
private fun onEventClick(event: TimelineItem.Event) {
|
||||||
|
return callbacks.forEach { it.onEventClick(event) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onUserDataClick(userId: UserId) {
|
||||||
|
callbacks.forEach { it.onUserDataClick(userId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onLinkClick(context: Context, url: String) {
|
||||||
|
when (val permalink = permalinkParser.parse(url)) {
|
||||||
|
is PermalinkData.UserLink -> {
|
||||||
|
// Open the room member profile, it will fallback to
|
||||||
|
// the user profile if the user is not in the room
|
||||||
|
callbacks.forEach { it.onUserDataClick(permalink.userId) }
|
||||||
|
}
|
||||||
|
is PermalinkData.RoomLink -> {
|
||||||
|
callbacks.forEach { it.onRoomPermalinkClick(permalink) }
|
||||||
|
}
|
||||||
|
is PermalinkData.FallbackLink,
|
||||||
|
is PermalinkData.RoomEmailInviteLink -> {
|
||||||
|
context.openUrlInExternalApp(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewInTimelineClick(eventId: EventId) {
|
||||||
|
callbacks.forEach { it.onViewInTimelineClick(eventId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
|
||||||
|
callbacks.forEach { it.onShowEventDebugInfoClick(eventId, debugInfo) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onForwardEventClick(eventId: EventId) {
|
||||||
|
callbacks.forEach { it.onForwardEventClick(eventId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun View(modifier: Modifier) {
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val state = presenter.present()
|
||||||
|
PinnedMessagesListView(
|
||||||
|
state = state,
|
||||||
|
onBackClick = ::navigateUp,
|
||||||
|
onEventClick = ::onEventClick,
|
||||||
|
onUserDataClick = ::onUserDataClick,
|
||||||
|
onLinkClick = { url -> onLinkClick(context, url) },
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,234 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2024 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.pinned.list
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.State
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedFactory
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
|
import io.element.android.features.messages.impl.UserEventPermissions
|
||||||
|
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
|
||||||
|
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||||
|
import io.element.android.features.messages.impl.pinned.PinnedEventsTimelineProvider
|
||||||
|
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
|
||||||
|
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||||
|
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||||
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
|
import io.element.android.libraries.matrix.api.room.isDm
|
||||||
|
import io.element.android.libraries.matrix.api.room.powerlevels.canPinUnpin
|
||||||
|
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther
|
||||||
|
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn
|
||||||
|
import io.element.android.libraries.matrix.api.room.roomMembers
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.debounce
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import timber.log.Timber
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
class PinnedMessagesListPresenter @AssistedInject constructor(
|
||||||
|
@Assisted private val navigator: PinnedMessagesListNavigator,
|
||||||
|
private val room: MatrixRoom,
|
||||||
|
private val timelineItemsFactory: TimelineItemsFactory,
|
||||||
|
private val timelineProvider: PinnedEventsTimelineProvider,
|
||||||
|
private val snackbarDispatcher: SnackbarDispatcher,
|
||||||
|
actionListPresenterFactory: ActionListPresenter.Factory,
|
||||||
|
) : Presenter<PinnedMessagesListState> {
|
||||||
|
@AssistedFactory
|
||||||
|
interface Factory {
|
||||||
|
fun create(navigator: PinnedMessagesListNavigator): PinnedMessagesListPresenter
|
||||||
|
}
|
||||||
|
|
||||||
|
private val actionListPresenter = actionListPresenterFactory.create(PinnedMessagesListTimelineActionPostProcessor())
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun present(): PinnedMessagesListState {
|
||||||
|
val timelineRoomInfo = remember {
|
||||||
|
TimelineRoomInfo(
|
||||||
|
isDm = room.isDm,
|
||||||
|
name = room.displayName,
|
||||||
|
// We don't need to compute those values
|
||||||
|
userHasPermissionToSendMessage = false,
|
||||||
|
userHasPermissionToSendReaction = false,
|
||||||
|
isCallOngoing = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
|
||||||
|
val userEventPermissions by userEventPermissions(syncUpdateFlow.value)
|
||||||
|
|
||||||
|
var pinnedMessageItems by remember {
|
||||||
|
mutableStateOf<AsyncData<ImmutableList<TimelineItem>>>(AsyncData.Uninitialized)
|
||||||
|
}
|
||||||
|
|
||||||
|
PinnedMessagesListEffect(
|
||||||
|
onItemsChange = { newItems ->
|
||||||
|
pinnedMessageItems = newItems
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
fun handleEvents(event: PinnedMessagesListEvents) {
|
||||||
|
when (event) {
|
||||||
|
is PinnedMessagesListEvents.HandleAction -> coroutineScope.handleTimelineAction(event.action, event.event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pinnedMessagesListState(
|
||||||
|
timelineRoomInfo = timelineRoomInfo,
|
||||||
|
userEventPermissions = userEventPermissions,
|
||||||
|
timelineItems = pinnedMessageItems,
|
||||||
|
eventSink = ::handleEvents
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CoroutineScope.handleTimelineAction(
|
||||||
|
action: TimelineItemAction,
|
||||||
|
targetEvent: TimelineItem.Event,
|
||||||
|
) = launch {
|
||||||
|
when (action) {
|
||||||
|
TimelineItemAction.Redact -> handleActionRedact(targetEvent)
|
||||||
|
TimelineItemAction.ViewSource -> {
|
||||||
|
navigator.onShowEventDebugInfoClick(targetEvent.eventId, targetEvent.debugInfo)
|
||||||
|
}
|
||||||
|
TimelineItemAction.Forward -> {
|
||||||
|
targetEvent.eventId?.let { eventId ->
|
||||||
|
navigator.onForwardEventClick(eventId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TimelineItemAction.Unpin -> handleUnpinAction(targetEvent)
|
||||||
|
TimelineItemAction.ViewInTimeline -> {
|
||||||
|
targetEvent.eventId?.let { eventId ->
|
||||||
|
navigator.onViewInTimelineClick(eventId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleUnpinAction(targetEvent: TimelineItem.Event) {
|
||||||
|
if (targetEvent.eventId == null) return
|
||||||
|
timelineProvider.invokeOnTimeline {
|
||||||
|
unpinEvent(targetEvent.eventId)
|
||||||
|
.onFailure {
|
||||||
|
Timber.e(it, "Failed to unpin event ${targetEvent.eventId}")
|
||||||
|
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleActionRedact(event: TimelineItem.Event) {
|
||||||
|
timelineProvider.invokeOnTimeline {
|
||||||
|
redactEvent(eventId = event.eventId, transactionId = event.transactionId, reason = null)
|
||||||
|
.onFailure { Timber.e(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun userEventPermissions(updateKey: Long): State<UserEventPermissions> {
|
||||||
|
return produceState(UserEventPermissions.DEFAULT, key1 = updateKey) {
|
||||||
|
value = UserEventPermissions(
|
||||||
|
canSendMessage = false,
|
||||||
|
canSendReaction = false,
|
||||||
|
canRedactOwn = room.canRedactOwn().getOrElse { false },
|
||||||
|
canRedactOther = room.canRedactOther().getOrElse { false },
|
||||||
|
canPinUnpin = room.canPinUnpin().getOrElse { false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(FlowPreview::class)
|
||||||
|
@Composable
|
||||||
|
private fun PinnedMessagesListEffect(onItemsChange: (AsyncData<ImmutableList<TimelineItem>>) -> Unit) {
|
||||||
|
val updatedOnItemsChange by rememberUpdatedState(onItemsChange)
|
||||||
|
|
||||||
|
val timelineState by timelineProvider.timelineStateFlow.collectAsState()
|
||||||
|
|
||||||
|
LaunchedEffect(timelineState) {
|
||||||
|
when (val asyncTimeline = timelineState) {
|
||||||
|
AsyncData.Uninitialized -> flowOf(AsyncData.Uninitialized)
|
||||||
|
is AsyncData.Failure -> flowOf(AsyncData.Failure(asyncTimeline.error))
|
||||||
|
is AsyncData.Loading -> flowOf(AsyncData.Loading())
|
||||||
|
is AsyncData.Success -> {
|
||||||
|
val timelineItemsFlow = asyncTimeline.data.timelineItems.debounce(300.milliseconds)
|
||||||
|
combine(timelineItemsFlow, room.membersStateFlow) { items, membersState ->
|
||||||
|
timelineItemsFactory.replaceWith(
|
||||||
|
timelineItems = items,
|
||||||
|
roomMembers = membersState.roomMembers().orEmpty()
|
||||||
|
)
|
||||||
|
}.launchIn(this)
|
||||||
|
|
||||||
|
timelineItemsFactory.timelineItems.map { timelineItems ->
|
||||||
|
AsyncData.Success(timelineItems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onEach { items ->
|
||||||
|
updatedOnItemsChange(items)
|
||||||
|
}
|
||||||
|
.launchIn(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun pinnedMessagesListState(
|
||||||
|
timelineRoomInfo: TimelineRoomInfo,
|
||||||
|
userEventPermissions: UserEventPermissions,
|
||||||
|
timelineItems: AsyncData<ImmutableList<TimelineItem>>,
|
||||||
|
eventSink: (PinnedMessagesListEvents) -> Unit
|
||||||
|
): PinnedMessagesListState {
|
||||||
|
return when (timelineItems) {
|
||||||
|
AsyncData.Uninitialized, is AsyncData.Loading -> PinnedMessagesListState.Loading
|
||||||
|
is AsyncData.Failure -> PinnedMessagesListState.Failed
|
||||||
|
is AsyncData.Success -> {
|
||||||
|
if (timelineItems.data.isEmpty()) {
|
||||||
|
PinnedMessagesListState.Empty
|
||||||
|
} else {
|
||||||
|
val actionListState = actionListPresenter.present()
|
||||||
|
PinnedMessagesListState.Filled(
|
||||||
|
timelineRoomInfo = timelineRoomInfo,
|
||||||
|
userEventPermissions = userEventPermissions,
|
||||||
|
timelineItems = timelineItems.data,
|
||||||
|
actionListState = actionListState,
|
||||||
|
eventSink = eventSink
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2024 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.pinned.list
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import io.element.android.features.messages.impl.UserEventPermissions
|
||||||
|
import io.element.android.features.messages.impl.actionlist.ActionListState
|
||||||
|
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
|
import io.element.android.libraries.ui.strings.CommonPlurals
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
sealed interface PinnedMessagesListState {
|
||||||
|
data object Failed : PinnedMessagesListState
|
||||||
|
data object Loading : PinnedMessagesListState
|
||||||
|
data object Empty : PinnedMessagesListState
|
||||||
|
data class Filled(
|
||||||
|
val timelineRoomInfo: TimelineRoomInfo,
|
||||||
|
val userEventPermissions: UserEventPermissions,
|
||||||
|
val timelineItems: ImmutableList<TimelineItem>,
|
||||||
|
val actionListState: ActionListState,
|
||||||
|
val eventSink: (PinnedMessagesListEvents) -> Unit,
|
||||||
|
) : PinnedMessagesListState {
|
||||||
|
val loadedPinnedMessagesCount = timelineItems.count { timelineItem -> timelineItem is TimelineItem.Event }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun title(): String {
|
||||||
|
return when (this) {
|
||||||
|
is Filled -> {
|
||||||
|
pluralStringResource(id = CommonPlurals.screen_pinned_timeline_screen_title, loadedPinnedMessagesCount, loadedPinnedMessagesCount)
|
||||||
|
}
|
||||||
|
else -> stringResource(id = CommonStrings.screen_pinned_timeline_screen_title_empty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2024 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.pinned.list
|
||||||
|
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
import io.element.android.features.messages.impl.UserEventPermissions
|
||||||
|
import io.element.android.features.messages.impl.actionlist.ActionListState
|
||||||
|
import io.element.android.features.messages.impl.actionlist.anActionListState
|
||||||
|
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
|
||||||
|
import io.element.android.features.messages.impl.timeline.aTimelineItemDaySeparator
|
||||||
|
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
||||||
|
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
|
||||||
|
import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemAudioContent
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemFileContent
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
|
||||||
|
open class PinnedMessagesListStateProvider : PreviewParameterProvider<PinnedMessagesListState> {
|
||||||
|
override val values: Sequence<PinnedMessagesListState>
|
||||||
|
get() = sequenceOf(
|
||||||
|
aFailedPinnedMessagesListState(),
|
||||||
|
aLoadingPinnedMessagesListState(),
|
||||||
|
anEmptyPinnedMessagesListState(),
|
||||||
|
aLoadedPinnedMessagesListState(
|
||||||
|
timelineItems = persistentListOf(
|
||||||
|
aTimelineItemEvent(
|
||||||
|
isMine = false,
|
||||||
|
content = aTimelineItemTextContent("A pinned message"),
|
||||||
|
groupPosition = TimelineItemGroupPosition.Last,
|
||||||
|
timelineItemReactions = aTimelineItemReactions(0)
|
||||||
|
),
|
||||||
|
aTimelineItemEvent(
|
||||||
|
isMine = false,
|
||||||
|
content = aTimelineItemAudioContent("A pinned file"),
|
||||||
|
groupPosition = TimelineItemGroupPosition.Middle,
|
||||||
|
timelineItemReactions = aTimelineItemReactions(0)
|
||||||
|
),
|
||||||
|
aTimelineItemEvent(
|
||||||
|
isMine = false,
|
||||||
|
content = aTimelineItemPollContent("A pinned poll?"),
|
||||||
|
groupPosition = TimelineItemGroupPosition.First,
|
||||||
|
timelineItemReactions = aTimelineItemReactions(0)
|
||||||
|
),
|
||||||
|
aTimelineItemDaySeparator(),
|
||||||
|
aTimelineItemEvent(
|
||||||
|
isMine = true,
|
||||||
|
content = aTimelineItemTextContent("A pinned message"),
|
||||||
|
groupPosition = TimelineItemGroupPosition.Last,
|
||||||
|
timelineItemReactions = aTimelineItemReactions(0)
|
||||||
|
),
|
||||||
|
aTimelineItemEvent(
|
||||||
|
isMine = true,
|
||||||
|
content = aTimelineItemFileContent("A pinned file?"),
|
||||||
|
groupPosition = TimelineItemGroupPosition.Middle,
|
||||||
|
timelineItemReactions = aTimelineItemReactions(0)
|
||||||
|
),
|
||||||
|
aTimelineItemEvent(
|
||||||
|
isMine = true,
|
||||||
|
content = aTimelineItemPollContent("A pinned poll?"),
|
||||||
|
groupPosition = TimelineItemGroupPosition.First,
|
||||||
|
timelineItemReactions = aTimelineItemReactions(0)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun aFailedPinnedMessagesListState() = PinnedMessagesListState.Failed
|
||||||
|
|
||||||
|
fun aLoadingPinnedMessagesListState() = PinnedMessagesListState.Loading
|
||||||
|
|
||||||
|
fun anEmptyPinnedMessagesListState() = PinnedMessagesListState.Empty
|
||||||
|
|
||||||
|
fun aLoadedPinnedMessagesListState(
|
||||||
|
timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(),
|
||||||
|
timelineItems: List<TimelineItem> = emptyList(),
|
||||||
|
actionListState: ActionListState = anActionListState(),
|
||||||
|
aUserEventPermissions: UserEventPermissions = UserEventPermissions.DEFAULT,
|
||||||
|
eventSink: (PinnedMessagesListEvents) -> Unit = {}
|
||||||
|
) = PinnedMessagesListState.Filled(
|
||||||
|
timelineRoomInfo = timelineRoomInfo,
|
||||||
|
timelineItems = timelineItems.toImmutableList(),
|
||||||
|
actionListState = actionListState,
|
||||||
|
userEventPermissions = aUserEventPermissions,
|
||||||
|
eventSink = eventSink,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2024 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.pinned.list
|
||||||
|
|
||||||
|
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||||
|
import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionPostProcessor
|
||||||
|
|
||||||
|
class PinnedMessagesListTimelineActionPostProcessor : TimelineItemActionPostProcessor {
|
||||||
|
override fun process(actions: List<TimelineItemAction>): List<TimelineItemAction> {
|
||||||
|
return buildList {
|
||||||
|
add(TimelineItemAction.ViewInTimeline)
|
||||||
|
actions.firstOrNull { it is TimelineItemAction.Unpin }?.let(::add)
|
||||||
|
actions.firstOrNull { it is TimelineItemAction.Forward }?.let(::add)
|
||||||
|
actions.firstOrNull { it is TimelineItemAction.ViewSource }?.let(::add)
|
||||||
|
actions.firstOrNull { it is TimelineItemAction.Redact }?.let(::add)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,272 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2024 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.pinned.list
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.compound.theme.ElementTheme
|
||||||
|
import io.element.android.features.messages.impl.actionlist.ActionListEvents
|
||||||
|
import io.element.android.features.messages.impl.actionlist.ActionListView
|
||||||
|
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||||
|
import io.element.android.features.messages.impl.timeline.components.TimelineItemRow
|
||||||
|
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
|
||||||
|
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
|
||||||
|
import io.element.android.features.poll.api.pollcontent.PollTitleView
|
||||||
|
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
|
||||||
|
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||||
|
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
||||||
|
import io.element.android.libraries.designsystem.icons.CompoundDrawables
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PinnedMessagesListView(
|
||||||
|
state: PinnedMessagesListState,
|
||||||
|
onBackClick: () -> Unit,
|
||||||
|
onEventClick: (event: TimelineItem.Event) -> Unit,
|
||||||
|
onUserDataClick: (UserId) -> Unit,
|
||||||
|
onLinkClick: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
topBar = {
|
||||||
|
PinnedMessagesListTopBar(state, onBackClick)
|
||||||
|
},
|
||||||
|
content = { padding ->
|
||||||
|
PinnedMessagesListContent(
|
||||||
|
state = state,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
onUserDataClick = onUserDataClick,
|
||||||
|
onLinkClick = onLinkClick,
|
||||||
|
onErrorDismiss = onBackClick,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(padding)
|
||||||
|
.consumeWindowInsets(padding),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun PinnedMessagesListTopBar(
|
||||||
|
state: PinnedMessagesListState,
|
||||||
|
onBackClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = state.title(),
|
||||||
|
style = ElementTheme.typography.fontBodyLgMedium
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = { BackButton(onClick = onBackClick) },
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PinnedMessagesListContent(
|
||||||
|
state: PinnedMessagesListState,
|
||||||
|
onEventClick: (event: TimelineItem.Event) -> Unit,
|
||||||
|
onUserDataClick: (UserId) -> Unit,
|
||||||
|
onLinkClick: (String) -> Unit,
|
||||||
|
onErrorDismiss: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Box(modifier.fillMaxSize()) {
|
||||||
|
when (state) {
|
||||||
|
PinnedMessagesListState.Failed -> {
|
||||||
|
ErrorDialog(
|
||||||
|
title = stringResource(id = CommonStrings.error_unknown),
|
||||||
|
content = stringResource(id = CommonStrings.error_failed_loading_messages),
|
||||||
|
onDismiss = onErrorDismiss
|
||||||
|
)
|
||||||
|
}
|
||||||
|
PinnedMessagesListState.Empty -> PinnedMessagesListEmpty()
|
||||||
|
is PinnedMessagesListState.Filled -> PinnedMessagesListLoaded(
|
||||||
|
state = state,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
onUserDataClick = onUserDataClick,
|
||||||
|
onLinkClick = onLinkClick,
|
||||||
|
)
|
||||||
|
PinnedMessagesListState.Loading -> {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PinnedMessagesListEmpty(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier.padding(
|
||||||
|
horizontal = 32.dp,
|
||||||
|
vertical = 48.dp,
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
val pinActionText = stringResource(id = CommonStrings.action_pin)
|
||||||
|
IconTitleSubtitleMolecule(
|
||||||
|
title = stringResource(id = CommonStrings.screen_pinned_timeline_empty_state_headline),
|
||||||
|
subTitle = stringResource(id = CommonStrings.screen_pinned_timeline_empty_state_description, pinActionText),
|
||||||
|
iconResourceId = CompoundDrawables.ic_compound_pin,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PinnedMessagesListLoaded(
|
||||||
|
state: PinnedMessagesListState.Filled,
|
||||||
|
onEventClick: (event: TimelineItem.Event) -> Unit,
|
||||||
|
onUserDataClick: (UserId) -> Unit,
|
||||||
|
onLinkClick: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
fun onActionSelected(timelineItemAction: TimelineItemAction, event: TimelineItem.Event) {
|
||||||
|
state.actionListState.eventSink(
|
||||||
|
ActionListEvents.Clear
|
||||||
|
)
|
||||||
|
state.eventSink(
|
||||||
|
PinnedMessagesListEvents.HandleAction(
|
||||||
|
action = timelineItemAction,
|
||||||
|
event = event,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onMessageLongClick(event: TimelineItem.Event) {
|
||||||
|
state.actionListState.eventSink(
|
||||||
|
ActionListEvents.ComputeForMessage(
|
||||||
|
event = event,
|
||||||
|
userEventPermissions = state.userEventPermissions,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ActionListView(
|
||||||
|
state = state.actionListState,
|
||||||
|
onSelectAction = ::onActionSelected,
|
||||||
|
onCustomReactionClick = {},
|
||||||
|
onEmojiReactionClick = { _, _ -> },
|
||||||
|
)
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
state = rememberLazyListState(),
|
||||||
|
reverseLayout = true,
|
||||||
|
contentPadding = PaddingValues(vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
items(
|
||||||
|
items = state.timelineItems,
|
||||||
|
contentType = { timelineItem -> timelineItem.contentType() },
|
||||||
|
key = { timelineItem -> timelineItem.identifier() },
|
||||||
|
) { timelineItem ->
|
||||||
|
TimelineItemRow(
|
||||||
|
timelineItem = timelineItem,
|
||||||
|
timelineRoomInfo = state.timelineRoomInfo,
|
||||||
|
renderReadReceipts = false,
|
||||||
|
isLastOutgoingMessage = false,
|
||||||
|
focusedEventId = null,
|
||||||
|
onClick = onEventClick,
|
||||||
|
onLongClick = ::onMessageLongClick,
|
||||||
|
onUserDataClick = onUserDataClick,
|
||||||
|
onLinkClick = onLinkClick,
|
||||||
|
inReplyToClick = {},
|
||||||
|
onReactionClick = { _, _ -> },
|
||||||
|
onReactionLongClick = { _, _ -> },
|
||||||
|
onMoreReactionsClick = {},
|
||||||
|
onReadReceiptClick = {},
|
||||||
|
eventSink = {},
|
||||||
|
onSwipeToReply = {},
|
||||||
|
onJoinCallClick = {},
|
||||||
|
onShieldClick = {},
|
||||||
|
eventContentView = { event, contentModifier, onContentLayoutChange ->
|
||||||
|
TimelineItemEventContentViewWrapper(
|
||||||
|
event = event,
|
||||||
|
onLinkClick = onLinkClick,
|
||||||
|
modifier = contentModifier,
|
||||||
|
onContentLayoutChange = onContentLayoutChange
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TimelineItemEventContentViewWrapper(
|
||||||
|
event: TimelineItem.Event,
|
||||||
|
onLinkClick: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
||||||
|
) {
|
||||||
|
if (event.content is TimelineItemPollContent) {
|
||||||
|
PollTitleView(
|
||||||
|
title = event.content.question,
|
||||||
|
isPollEnded = event.content.isEnded,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
TimelineItemEventContentView(
|
||||||
|
content = event.content,
|
||||||
|
onLinkClick = onLinkClick,
|
||||||
|
eventSink = { },
|
||||||
|
modifier = modifier,
|
||||||
|
onContentLayoutChange = onContentLayoutChange
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun PinnedMessagesListViewPreview(@PreviewParameter(PinnedMessagesListStateProvider::class) state: PinnedMessagesListState) =
|
||||||
|
ElementPreview {
|
||||||
|
PinnedMessagesListView(
|
||||||
|
state = state,
|
||||||
|
onBackClick = {},
|
||||||
|
onEventClick = { },
|
||||||
|
onUserDataClick = {},
|
||||||
|
onLinkClick = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -51,6 +51,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemE
|
||||||
import io.element.android.libraries.matrix.ui.room.canSendMessageAsState
|
import io.element.android.libraries.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()
|
||||||
|
|
|
||||||
|
|
@ -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 ->
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2024 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.actionlist
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionPostProcessor
|
||||||
|
|
||||||
|
class FakeActionListPresenter : ActionListPresenter {
|
||||||
|
object Factory : ActionListPresenter.Factory {
|
||||||
|
override fun create(postProcessor: TimelineItemActionPostProcessor): ActionListPresenter {
|
||||||
|
return FakeActionListPresenter()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun present(): ActionListState {
|
||||||
|
return anActionListState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,9 +17,12 @@
|
||||||
package io.element.android.features.messages.impl.pinned.banner
|
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,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2024 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.pinned.list
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||||
|
|
||||||
|
class FakePinnedMessagesListNavigator : PinnedMessagesListNavigator {
|
||||||
|
var onViewInTimelineClickLambda: ((EventId) -> Unit)? = null
|
||||||
|
override fun onViewInTimelineClick(eventId: EventId) {
|
||||||
|
onViewInTimelineClickLambda?.invoke(eventId)
|
||||||
|
}
|
||||||
|
|
||||||
|
var onShowEventDebugInfoClickLambda: ((EventId?, TimelineItemDebugInfo) -> Unit)? = null
|
||||||
|
override fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
|
||||||
|
onShowEventDebugInfoClickLambda?.invoke(eventId, debugInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
var onForwardEventClickLambda: ((EventId) -> Unit)? = null
|
||||||
|
override fun onForwardEventClick(eventId: EventId) {
|
||||||
|
onForwardEventClickLambda?.invoke(eventId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,336 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2024 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.pinned.list
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.features.messages.impl.actionlist.FakeActionListPresenter
|
||||||
|
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||||
|
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactory
|
||||||
|
import io.element.android.features.messages.impl.pinned.PinnedEventsTimelineProvider
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
|
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
||||||
|
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
|
||||||
|
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||||
|
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||||
|
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.core.TransactionId
|
||||||
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||||
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_THROWABLE
|
||||||
|
import io.element.android.libraries.matrix.test.A_UNIQUE_ID
|
||||||
|
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||||
|
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
||||||
|
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||||
|
import io.element.android.libraries.matrix.test.timeline.aMessageContent
|
||||||
|
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
|
||||||
|
import io.element.android.tests.testutils.lambda.assert
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
|
import io.element.android.tests.testutils.lambda.value
|
||||||
|
import io.element.android.tests.testutils.test
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class PinnedMessagesListPresenterTest {
|
||||||
|
@Test
|
||||||
|
fun `present - initial state feature disabled`() = runTest {
|
||||||
|
val room = FakeMatrixRoom(
|
||||||
|
canRedactOwnResult = { Result.success(true) },
|
||||||
|
canRedactOtherResult = { Result.success(true) },
|
||||||
|
canUserPinUnpinResult = { Result.success(true) },
|
||||||
|
)
|
||||||
|
val presenter = createPinnedMessagesListPresenter(room = room, isFeatureEnabled = false)
|
||||||
|
presenter.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState).isEqualTo(PinnedMessagesListState.Loading)
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - initial state feature enabled`() = runTest {
|
||||||
|
val room = FakeMatrixRoom(
|
||||||
|
canRedactOwnResult = { Result.success(true) },
|
||||||
|
canRedactOtherResult = { Result.success(true) },
|
||||||
|
canUserPinUnpinResult = { Result.success(true) },
|
||||||
|
).apply {
|
||||||
|
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
|
||||||
|
}
|
||||||
|
val presenter = createPinnedMessagesListPresenter(room = room, isFeatureEnabled = true)
|
||||||
|
presenter.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState).isEqualTo(PinnedMessagesListState.Loading)
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - timeline failure state`() = runTest {
|
||||||
|
val room = FakeMatrixRoom(
|
||||||
|
pinnedEventsTimelineResult = { Result.failure(RuntimeException()) },
|
||||||
|
canRedactOwnResult = { Result.success(true) },
|
||||||
|
canRedactOtherResult = { Result.success(true) },
|
||||||
|
canUserPinUnpinResult = { Result.success(true) },
|
||||||
|
).apply {
|
||||||
|
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
|
||||||
|
}
|
||||||
|
val presenter = createPinnedMessagesListPresenter(room = room, isFeatureEnabled = true)
|
||||||
|
presenter.test {
|
||||||
|
skipItems(3)
|
||||||
|
val failureState = awaitItem()
|
||||||
|
assertThat(failureState).isEqualTo(PinnedMessagesListState.Failed)
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - empty state`() = runTest {
|
||||||
|
val room = FakeMatrixRoom(
|
||||||
|
pinnedEventsTimelineResult = { Result.success(FakeTimeline()) },
|
||||||
|
canRedactOwnResult = { Result.success(true) },
|
||||||
|
canRedactOtherResult = { Result.success(true) },
|
||||||
|
canUserPinUnpinResult = { Result.success(true) },
|
||||||
|
).apply {
|
||||||
|
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf()))
|
||||||
|
}
|
||||||
|
val presenter = createPinnedMessagesListPresenter(room = room, isFeatureEnabled = true)
|
||||||
|
presenter.test {
|
||||||
|
skipItems(3)
|
||||||
|
val emptyState = awaitItem()
|
||||||
|
assertThat(emptyState).isEqualTo(PinnedMessagesListState.Empty)
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - filled state`() = runTest {
|
||||||
|
val pinnedEventsTimeline = createPinnedMessagesTimeline()
|
||||||
|
val room = FakeMatrixRoom(
|
||||||
|
pinnedEventsTimelineResult = { Result.success(pinnedEventsTimeline) },
|
||||||
|
canRedactOwnResult = { Result.success(true) },
|
||||||
|
canRedactOtherResult = { Result.success(true) },
|
||||||
|
canUserPinUnpinResult = { Result.success(true) },
|
||||||
|
).apply {
|
||||||
|
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
|
||||||
|
}
|
||||||
|
val presenter = createPinnedMessagesListPresenter(room = room, isFeatureEnabled = true)
|
||||||
|
presenter.test {
|
||||||
|
skipItems(3)
|
||||||
|
val filledState = awaitItem() as PinnedMessagesListState.Filled
|
||||||
|
assertThat(filledState.timelineItems).hasSize(1)
|
||||||
|
assertThat(filledState.loadedPinnedMessagesCount).isEqualTo(1)
|
||||||
|
assertThat(filledState.userEventPermissions.canRedactOwn).isTrue()
|
||||||
|
assertThat(filledState.userEventPermissions.canRedactOther).isTrue()
|
||||||
|
assertThat(filledState.userEventPermissions.canPinUnpin).isTrue()
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - redact event`() = runTest {
|
||||||
|
val redactEventLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String? -> Result.success(true) }
|
||||||
|
val pinnedEventsTimeline = createPinnedMessagesTimeline().apply {
|
||||||
|
this.redactEventLambda = redactEventLambda
|
||||||
|
}
|
||||||
|
val room = FakeMatrixRoom(
|
||||||
|
pinnedEventsTimelineResult = { Result.success(pinnedEventsTimeline) },
|
||||||
|
canRedactOwnResult = { Result.success(true) },
|
||||||
|
canRedactOtherResult = { Result.success(true) },
|
||||||
|
canUserPinUnpinResult = { Result.success(true) },
|
||||||
|
).apply {
|
||||||
|
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
|
||||||
|
}
|
||||||
|
val presenter = createPinnedMessagesListPresenter(room = room, isFeatureEnabled = true)
|
||||||
|
presenter.test {
|
||||||
|
skipItems(3)
|
||||||
|
val filledState = awaitItem() as PinnedMessagesListState.Filled
|
||||||
|
val eventItem = filledState.timelineItems.first() as TimelineItem.Event
|
||||||
|
filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.Redact, eventItem))
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
assert(redactEventLambda)
|
||||||
|
.isCalledOnce()
|
||||||
|
.with(value(AN_EVENT_ID), value(null), value(null))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - unpin event`() = runTest {
|
||||||
|
val successUnpinEventLambda = lambdaRecorder { _: EventId? -> Result.success(true) }
|
||||||
|
val failureUnpinEventLambda = lambdaRecorder { _: EventId? -> Result.failure<Boolean>(A_THROWABLE) }
|
||||||
|
val pinnedEventsTimeline = createPinnedMessagesTimeline()
|
||||||
|
|
||||||
|
val room = FakeMatrixRoom(
|
||||||
|
pinnedEventsTimelineResult = { Result.success(pinnedEventsTimeline) },
|
||||||
|
canRedactOwnResult = { Result.success(true) },
|
||||||
|
canRedactOtherResult = { Result.success(true) },
|
||||||
|
canUserPinUnpinResult = { Result.success(true) },
|
||||||
|
).apply {
|
||||||
|
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
|
||||||
|
}
|
||||||
|
val presenter = createPinnedMessagesListPresenter(room = room, isFeatureEnabled = true)
|
||||||
|
presenter.test {
|
||||||
|
skipItems(3)
|
||||||
|
val filledState = awaitItem() as PinnedMessagesListState.Filled
|
||||||
|
val eventItem = filledState.timelineItems.first() as TimelineItem.Event
|
||||||
|
|
||||||
|
pinnedEventsTimeline.unpinEventLambda = successUnpinEventLambda
|
||||||
|
filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.Unpin, eventItem))
|
||||||
|
|
||||||
|
pinnedEventsTimeline.unpinEventLambda = failureUnpinEventLambda
|
||||||
|
filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.Unpin, eventItem))
|
||||||
|
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
|
||||||
|
assert(successUnpinEventLambda)
|
||||||
|
.isCalledOnce()
|
||||||
|
.with(value(AN_EVENT_ID))
|
||||||
|
|
||||||
|
assert(failureUnpinEventLambda)
|
||||||
|
.isCalledOnce()
|
||||||
|
.with(value(AN_EVENT_ID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - navigate to event`() = runTest {
|
||||||
|
val onViewInTimelineClickLambda = lambdaRecorder { _: EventId -> }
|
||||||
|
val navigator = FakePinnedMessagesListNavigator().apply {
|
||||||
|
this.onViewInTimelineClickLambda = onViewInTimelineClickLambda
|
||||||
|
}
|
||||||
|
val pinnedEventsTimeline = createPinnedMessagesTimeline()
|
||||||
|
val room = FakeMatrixRoom(
|
||||||
|
pinnedEventsTimelineResult = { Result.success(pinnedEventsTimeline) },
|
||||||
|
canRedactOwnResult = { Result.success(true) },
|
||||||
|
canRedactOtherResult = { Result.success(true) },
|
||||||
|
canUserPinUnpinResult = { Result.success(true) },
|
||||||
|
).apply {
|
||||||
|
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
|
||||||
|
}
|
||||||
|
val presenter = createPinnedMessagesListPresenter(room = room, navigator = navigator, isFeatureEnabled = true)
|
||||||
|
presenter.test {
|
||||||
|
skipItems(3)
|
||||||
|
val filledState = awaitItem() as PinnedMessagesListState.Filled
|
||||||
|
val eventItem = filledState.timelineItems.first() as TimelineItem.Event
|
||||||
|
filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.ViewInTimeline, eventItem))
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
assert(onViewInTimelineClickLambda)
|
||||||
|
.isCalledOnce()
|
||||||
|
.with(value(AN_EVENT_ID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - show view source action`() = runTest {
|
||||||
|
val onShowEventDebugInfoClickLambda = lambdaRecorder { _: EventId?, _: TimelineItemDebugInfo -> }
|
||||||
|
val navigator = FakePinnedMessagesListNavigator().apply {
|
||||||
|
this.onShowEventDebugInfoClickLambda = onShowEventDebugInfoClickLambda
|
||||||
|
}
|
||||||
|
val pinnedEventsTimeline = createPinnedMessagesTimeline()
|
||||||
|
val room = FakeMatrixRoom(
|
||||||
|
pinnedEventsTimelineResult = { Result.success(pinnedEventsTimeline) },
|
||||||
|
canRedactOwnResult = { Result.success(true) },
|
||||||
|
canRedactOtherResult = { Result.success(true) },
|
||||||
|
canUserPinUnpinResult = { Result.success(true) },
|
||||||
|
).apply {
|
||||||
|
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
|
||||||
|
}
|
||||||
|
val presenter = createPinnedMessagesListPresenter(room = room, navigator = navigator, isFeatureEnabled = true)
|
||||||
|
presenter.test {
|
||||||
|
skipItems(3)
|
||||||
|
val filledState = awaitItem() as PinnedMessagesListState.Filled
|
||||||
|
val eventItem = filledState.timelineItems.first() as TimelineItem.Event
|
||||||
|
filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.ViewSource, eventItem))
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
assert(onShowEventDebugInfoClickLambda)
|
||||||
|
.isCalledOnce()
|
||||||
|
.with(value(AN_EVENT_ID), value(eventItem.debugInfo))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - forward event`() = runTest {
|
||||||
|
val onForwardEventClickLambda = lambdaRecorder { _: EventId -> }
|
||||||
|
val navigator = FakePinnedMessagesListNavigator().apply {
|
||||||
|
this.onForwardEventClickLambda = onForwardEventClickLambda
|
||||||
|
}
|
||||||
|
val pinnedEventsTimeline = createPinnedMessagesTimeline()
|
||||||
|
val room = FakeMatrixRoom(
|
||||||
|
pinnedEventsTimelineResult = { Result.success(pinnedEventsTimeline) },
|
||||||
|
canRedactOwnResult = { Result.success(true) },
|
||||||
|
canRedactOtherResult = { Result.success(true) },
|
||||||
|
canUserPinUnpinResult = { Result.success(true) },
|
||||||
|
).apply {
|
||||||
|
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
|
||||||
|
}
|
||||||
|
val presenter = createPinnedMessagesListPresenter(room = room, navigator = navigator, isFeatureEnabled = true)
|
||||||
|
presenter.test {
|
||||||
|
skipItems(3)
|
||||||
|
val filledState = awaitItem() as PinnedMessagesListState.Filled
|
||||||
|
val eventItem = filledState.timelineItems.first() as TimelineItem.Event
|
||||||
|
filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.Forward, eventItem))
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
assert(onForwardEventClickLambda)
|
||||||
|
.isCalledOnce()
|
||||||
|
.with(value(AN_EVENT_ID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createPinnedMessagesTimeline(): FakeTimeline {
|
||||||
|
val messageContent = aMessageContent("A message")
|
||||||
|
return FakeTimeline(
|
||||||
|
timelineItems = flowOf(
|
||||||
|
listOf(
|
||||||
|
MatrixTimelineItem.Event(
|
||||||
|
uniqueId = A_UNIQUE_ID,
|
||||||
|
event = anEventTimelineItem(
|
||||||
|
eventId = AN_EVENT_ID,
|
||||||
|
content = messageContent,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TestScope.createPinnedMessagesListPresenter(
|
||||||
|
navigator: PinnedMessagesListNavigator = FakePinnedMessagesListNavigator(),
|
||||||
|
room: MatrixRoom = FakeMatrixRoom(),
|
||||||
|
networkMonitor: NetworkMonitor = FakeNetworkMonitor(),
|
||||||
|
isFeatureEnabled: Boolean = true,
|
||||||
|
): PinnedMessagesListPresenter {
|
||||||
|
val timelineProvider = PinnedEventsTimelineProvider(
|
||||||
|
room = room,
|
||||||
|
networkMonitor = networkMonitor,
|
||||||
|
featureFlagService = FakeFeatureFlagService(
|
||||||
|
initialState = mapOf(FeatureFlags.PinnedEvents.key to isFeatureEnabled)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
timelineProvider.launchIn(backgroundScope)
|
||||||
|
return PinnedMessagesListPresenter(
|
||||||
|
navigator = navigator,
|
||||||
|
room = room,
|
||||||
|
timelineItemsFactory = aTimelineItemsFactory(),
|
||||||
|
timelineProvider = timelineProvider,
|
||||||
|
snackbarDispatcher = SnackbarDispatcher(),
|
||||||
|
actionListPresenterFactory = FakeActionListPresenter.Factory,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2024 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.pinned.list
|
||||||
|
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.longClick
|
||||||
|
import androidx.compose.ui.test.onAllNodesWithText
|
||||||
|
import androidx.compose.ui.test.onFirst
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.compose.ui.test.performTouchInput
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import io.element.android.features.messages.impl.actionlist.ActionListEvents
|
||||||
|
import io.element.android.features.messages.impl.actionlist.anActionListState
|
||||||
|
import io.element.android.features.messages.impl.timeline.aTimelineItemList
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemFileContent
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import io.element.android.tests.testutils.EnsureNeverCalled
|
||||||
|
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
|
||||||
|
import io.element.android.tests.testutils.EventsRecorder
|
||||||
|
import io.element.android.tests.testutils.ensureCalledOnce
|
||||||
|
import io.element.android.tests.testutils.ensureCalledOnceWithParam
|
||||||
|
import io.element.android.tests.testutils.pressBack
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.rules.TestRule
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class PinnedMessagesListViewTest {
|
||||||
|
@get:Rule
|
||||||
|
val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `clicking on back calls the expected callback`() {
|
||||||
|
val eventsRecorder = EventsRecorder<PinnedMessagesListEvents>(expectEvents = false)
|
||||||
|
val state = aLoadedPinnedMessagesListState(
|
||||||
|
eventSink = eventsRecorder
|
||||||
|
)
|
||||||
|
ensureCalledOnce { callback ->
|
||||||
|
rule.setPinnedMessagesListView(
|
||||||
|
state = state,
|
||||||
|
onBackClick = callback
|
||||||
|
)
|
||||||
|
rule.pressBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `click on an event calls the expected callback`() {
|
||||||
|
val eventsRecorder = EventsRecorder<PinnedMessagesListEvents>(expectEvents = false)
|
||||||
|
val content = aTimelineItemFileContent()
|
||||||
|
val state = aLoadedPinnedMessagesListState(
|
||||||
|
timelineItems = aTimelineItemList(content),
|
||||||
|
eventSink = eventsRecorder
|
||||||
|
)
|
||||||
|
|
||||||
|
val event = state.timelineItems.first() as TimelineItem.Event
|
||||||
|
ensureCalledOnceWithParam(event) { callback ->
|
||||||
|
rule.setPinnedMessagesListView(
|
||||||
|
state = state,
|
||||||
|
onEventClick = callback
|
||||||
|
)
|
||||||
|
rule.onAllNodesWithText(content.body).onFirst().performClick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `long click on an event emits the expected event`() {
|
||||||
|
val eventsRecorder = EventsRecorder<ActionListEvents>(expectEvents = true)
|
||||||
|
val content = aTimelineItemFileContent()
|
||||||
|
val state = aLoadedPinnedMessagesListState(
|
||||||
|
timelineItems = aTimelineItemList(content),
|
||||||
|
actionListState = anActionListState(eventSink = eventsRecorder)
|
||||||
|
)
|
||||||
|
|
||||||
|
rule.setPinnedMessagesListView(
|
||||||
|
state = state,
|
||||||
|
)
|
||||||
|
rule.onAllNodesWithText(content.body).onFirst()
|
||||||
|
.performTouchInput {
|
||||||
|
longClick()
|
||||||
|
}
|
||||||
|
val event = state.timelineItems.first() as TimelineItem.Event
|
||||||
|
eventsRecorder.assertSingle(ActionListEvents.ComputeForMessage(event, state.userEventPermissions))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setPinnedMessagesListView(
|
||||||
|
state: PinnedMessagesListState,
|
||||||
|
onBackClick: () -> Unit = EnsureNeverCalled(),
|
||||||
|
onEventClick: (event: TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
|
||||||
|
onUserDataClick: (UserId) -> Unit = EnsureNeverCalledWithParam(),
|
||||||
|
onLinkClick: (String) -> Unit = EnsureNeverCalledWithParam(),
|
||||||
|
) {
|
||||||
|
setContent {
|
||||||
|
PinnedMessagesListView(
|
||||||
|
state = state,
|
||||||
|
onBackClick = onBackClick,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
onUserDataClick = onUserDataClick,
|
||||||
|
onLinkClick = onLinkClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,10 +19,8 @@ package io.element.android.features.poll.api.pollcontent
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.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>,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2024 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.poll.api.pollcontent
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.compound.theme.ElementTheme
|
||||||
|
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PollTitleView(
|
||||||
|
title: String,
|
||||||
|
isPollEnded: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
if (isPollEnded) {
|
||||||
|
Icon(
|
||||||
|
imageVector = CompoundIcons.PollsEnd(),
|
||||||
|
contentDescription = stringResource(id = CommonStrings.a11y_poll_end),
|
||||||
|
modifier = Modifier.size(22.dp)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
imageVector = CompoundIcons.Polls(),
|
||||||
|
contentDescription = stringResource(id = CommonStrings.a11y_poll),
|
||||||
|
modifier = Modifier.size(22.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = ElementTheme.typography.fontBodyLgMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun PollTitleViewPreview() = ElementPreview {
|
||||||
|
PollTitleView(
|
||||||
|
title = "What is your favorite color?",
|
||||||
|
isPollEnded = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -32,8 +32,8 @@ import io.element.android.features.poll.impl.history.model.PollHistoryFilter
|
||||||
import io.element.android.features.poll.impl.history.model.PollHistoryItems
|
import io.element.android.features.poll.impl.history.model.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 ->
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2024 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.core.coroutine
|
||||||
|
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.FlowCollector
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A [StateFlow] that derives its value from a [Flow].
|
||||||
|
* Useful when you want to apply transformations to a [Flow] and expose it as a [StateFlow].
|
||||||
|
*/
|
||||||
|
class DerivedStateFlow<T>(
|
||||||
|
private val getValue: () -> T,
|
||||||
|
private val flow: Flow<T>
|
||||||
|
) : StateFlow<T> {
|
||||||
|
override val replayCache: List<T>
|
||||||
|
get() = listOf(value)
|
||||||
|
|
||||||
|
override val value: T
|
||||||
|
get() = getValue()
|
||||||
|
|
||||||
|
override suspend fun collect(collector: FlowCollector<T>): Nothing {
|
||||||
|
coroutineScope { flow.distinctUntilChanged().stateIn(this).collect(collector) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps the value of a [StateFlow] to a new value and returns a new [StateFlow] with the mapped value.
|
||||||
|
*/
|
||||||
|
fun <T1, R> StateFlow<T1>.mapState(transform: (a: T1) -> R): StateFlow<R> {
|
||||||
|
return DerivedStateFlow(
|
||||||
|
getValue = { transform(this.value) },
|
||||||
|
flow = this.map { a -> transform(a) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -42,6 +42,7 @@ dependencies {
|
||||||
implementation(libs.serialization.json)
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:da5fed3d33c60f471b19fcf9761c5feb1e8fff303d84e951ecd53e8cd54613d0
|
||||||
|
size 19938
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:fea6dbb47a656e0d39181a40b32277e17f0fe52c99e8c1519d5f09ff9423e98c
|
||||||
|
size 8983
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:54a495cd353317e667d667d3f98c4b844568b0375d83b7cb4091bafd30dec194
|
||||||
|
size 31738
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:6fee1e714cc759547034e8e688e69ee525336e18febbbc118b7e1fdabe159a60
|
||||||
|
size 42698
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:93fe898626703045ac2efc2c5c9c2a404a9fa446333649ab77cf1acd7da4f1e7
|
||||||
|
size 18057
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:f221c0af78be8d43a481ff0b122ea0587e0e0618c6f6fec78cb91368e940f701
|
||||||
|
size 8720
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:97ae5c5a01f94e35d2d05d3b1a6f3be992fcf49f9674bdf913753c5430d3e0bf
|
||||||
|
size 30749
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:7a16fcbc220880ccd0280e734bcdb6184b5a34a7505708f8f7795b6431cf6f41
|
||||||
|
size 41511
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:73d64e6f6fd0f979c1929f3f1118d26890e55c301031dd3ca0f64647429dd42c
|
||||||
|
size 9358
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:e5b23e028c2df95e19ee96b0715b3da7f32ea15d7b50a6bd6068add0263c0c2a
|
||||||
|
size 9081
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:d2e96d71fd2e0e67415ab5d69b2712661f4e453980e202e60717cc73e1e3fa81
|
oid sha256:8cef0a991d7b6a66dac0687e278231dcd4c9084e3ea04fc5e28d7d15f8b30795
|
||||||
size 45821
|
size 40901
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:c2ed5843b61db2188b6673a07f899966ed041cd5acfe12dcf40539d96e77eaab
|
||||||
|
size 45455
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:4885f85102e8e8a509586fb48e7a26d5ceb1dd7aa6897b955cb781eed87b9f30
|
oid sha256:85e674e93c73ddbe77c71bfa8ca6f117b9f0d6863fa5137f12f0e9ed62a6e4b7
|
||||||
size 35023
|
size 37414
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:f346764da31583076508550c339be523d15f338b00f2f412e1b117bd1e3b5062
|
||||||
|
size 46333
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:09ce1743e0e3c5629fab6d0c2d06cc15be7229e446673b05f49f0e031a1c2823
|
oid sha256:17a68abaa010d037c00b3ae24ab470c9cf2e7b42d1c45391819b5e46e3b3c2c7
|
||||||
size 35776
|
size 38265
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue