Merge pull request #3392 from element-hq/feature/fga/pinned_messages_list
[Feature] Pinned messages list
This commit is contained in:
commit
b802a196fc
98 changed files with 2279 additions and 357 deletions
|
|
@ -16,6 +16,7 @@
|
|||
|
||||
plugins {
|
||||
id("io.element.android-compose-library")
|
||||
id("kotlin-parcelize")
|
||||
}
|
||||
|
||||
android {
|
||||
|
|
|
|||
|
|
@ -16,17 +16,26 @@
|
|||
|
||||
package io.element.android.features.messages.api
|
||||
|
||||
import android.os.Parcelable
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import io.element.android.libraries.architecture.FeatureEntryPoint
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
interface MessagesEntryPoint : FeatureEntryPoint {
|
||||
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
|
||||
sealed interface InitialTarget : Parcelable {
|
||||
@Parcelize
|
||||
data class Messages(val focusedEventId: EventId?) : InitialTarget
|
||||
|
||||
@Parcelize
|
||||
data object PinnedMessages : InitialTarget
|
||||
}
|
||||
|
||||
interface NodeBuilder {
|
||||
fun params(params: Params): NodeBuilder
|
||||
|
|
@ -34,14 +43,14 @@ interface MessagesEntryPoint : FeatureEntryPoint {
|
|||
fun build(): Node
|
||||
}
|
||||
|
||||
data class Params(
|
||||
val focusedEventId: EventId?,
|
||||
)
|
||||
|
||||
interface Callback : Plugin {
|
||||
fun onRoomDetailsClick()
|
||||
fun onUserDataClick(userId: UserId)
|
||||
fun onPermalinkClick(data: PermalinkData)
|
||||
fun onPermalinkClick(data: PermalinkData, pushToBackstack: Boolean)
|
||||
fun onForwardedToSingleRoom(roomId: RoomId)
|
||||
}
|
||||
|
||||
data class Params(val initialTarget: InitialTarget) : NodeInputs
|
||||
|
||||
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.api.pinned
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
fun interface IsPinnedMessagesFeatureEnabled {
|
||||
@Composable
|
||||
operator fun invoke(): Boolean
|
||||
}
|
||||
|
|
@ -32,7 +32,7 @@ class DefaultMessagesEntryPoint @Inject constructor() : MessagesEntryPoint {
|
|||
|
||||
return object : MessagesEntryPoint.NodeBuilder {
|
||||
override fun params(params: MessagesEntryPoint.Params): MessagesEntryPoint.NodeBuilder {
|
||||
plugins += MessagesFlowNode.Inputs(focusedEventId = params.focusedEventId)
|
||||
plugins += MessagesEntryPoint.Params(params.initialTarget)
|
||||
return this
|
||||
}
|
||||
|
||||
|
|
@ -47,3 +47,8 @@ class DefaultMessagesEntryPoint @Inject constructor() : MessagesEntryPoint {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun MessagesEntryPoint.InitialTarget.toNavTarget() = when (this) {
|
||||
is MessagesEntryPoint.InitialTarget.Messages -> MessagesFlowNode.NavTarget.Messages(focusedEventId)
|
||||
MessagesEntryPoint.InitialTarget.PinnedMessages -> MessagesFlowNode.NavTarget.PinnedMessagesList
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.bumble.appyx.core.lifecycle.subscribe
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.node.node
|
||||
|
|
@ -41,7 +42,10 @@ import io.element.android.features.messages.api.MessagesEntryPoint
|
|||
import io.element.android.features.messages.impl.attachments.Attachment
|
||||
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode
|
||||
import io.element.android.features.messages.impl.forward.ForwardMessagesNode
|
||||
import io.element.android.features.messages.impl.pinned.PinnedEventsTimelineProvider
|
||||
import io.element.android.features.messages.impl.pinned.list.PinnedMessagesListNode
|
||||
import io.element.android.features.messages.impl.report.ReportMessageNode
|
||||
import io.element.android.features.messages.impl.timeline.TimelineController
|
||||
import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNode
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
|
||||
|
|
@ -54,7 +58,6 @@ import io.element.android.features.poll.api.create.CreatePollEntryPoint
|
|||
import io.element.android.features.poll.api.create.CreatePollMode
|
||||
import io.element.android.libraries.architecture.BackstackWithOverlayBox
|
||||
import io.element.android.libraries.architecture.BaseFlowNode
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.architecture.inputs
|
||||
import io.element.android.libraries.architecture.overlay.Overlay
|
||||
|
|
@ -64,9 +67,11 @@ import io.element.android.libraries.matrix.api.MatrixClient
|
|||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.alias.matches
|
||||
import io.element.android.libraries.matrix.api.room.joinedRoomMembers
|
||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||
import io.element.android.libraries.matrix.ui.messages.LocalRoomMemberProfilesCache
|
||||
|
|
@ -81,7 +86,6 @@ import kotlinx.collections.immutable.ImmutableList
|
|||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import timber.log.Timber
|
||||
|
||||
@ContributesNode(RoomScope::class)
|
||||
class MessagesFlowNode @AssistedInject constructor(
|
||||
|
|
@ -96,9 +100,11 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
private val room: MatrixRoom,
|
||||
private val roomMemberProfilesCache: RoomMemberProfilesCache,
|
||||
private val mentionSpanTheme: MentionSpanTheme,
|
||||
private val pinnedEventsTimelineProvider: PinnedEventsTimelineProvider,
|
||||
private val timelineController: TimelineController,
|
||||
) : BaseFlowNode<MessagesFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = NavTarget.Messages,
|
||||
initialElement = plugins.filterIsInstance<MessagesEntryPoint.Params>().first().initialTarget.toNavTarget(),
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
),
|
||||
overlay = Overlay(
|
||||
|
|
@ -107,16 +113,12 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
buildContext = buildContext,
|
||||
plugins = plugins
|
||||
) {
|
||||
data class Inputs(val focusedEventId: EventId?) : NodeInputs
|
||||
|
||||
private val inputs = inputs<Inputs>()
|
||||
|
||||
sealed interface NavTarget : Parcelable {
|
||||
@Parcelize
|
||||
data object Empty : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object Messages : NavTarget
|
||||
data class Messages(val focusedEventId: EventId?) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class MediaViewer(
|
||||
|
|
@ -135,7 +137,7 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
data class EventDebugInfo(val eventId: EventId?, val debugInfo: TimelineItemDebugInfo) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class ForwardEvent(val eventId: EventId) : NavTarget
|
||||
data class ForwardEvent(val eventId: EventId, val fromPinnedEvents: Boolean) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class ReportMessage(val eventId: EventId, val senderId: UserId) : NavTarget
|
||||
|
|
@ -148,18 +150,27 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
|
||||
@Parcelize
|
||||
data class EditPoll(val eventId: EventId) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object PinnedMessagesList : NavTarget
|
||||
}
|
||||
|
||||
private val callbacks = plugins<MessagesEntryPoint.Callback>()
|
||||
|
||||
override fun onBuilt() {
|
||||
super.onBuilt()
|
||||
|
||||
lifecycle.subscribe(
|
||||
onDestroy = {
|
||||
timelineController.close()
|
||||
}
|
||||
)
|
||||
room.membersStateFlow
|
||||
.onEach { membersState ->
|
||||
roomMemberProfilesCache.replace(membersState.joinedRoomMembers())
|
||||
}
|
||||
.launchIn(lifecycleScope)
|
||||
|
||||
pinnedEventsTimelineProvider.launchIn(lifecycleScope)
|
||||
}
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
|
|
@ -183,7 +194,7 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
}
|
||||
|
||||
override fun onPermalinkClick(data: PermalinkData) {
|
||||
callbacks.forEach { it.onPermalinkClick(data) }
|
||||
callbacks.forEach { it.onPermalinkClick(data, pushToBackstack = true) }
|
||||
}
|
||||
|
||||
override fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
|
||||
|
|
@ -191,7 +202,7 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
}
|
||||
|
||||
override fun onForwardEventClick(eventId: EventId) {
|
||||
backstack.push(NavTarget.ForwardEvent(eventId))
|
||||
backstack.push(NavTarget.ForwardEvent(eventId, fromPinnedEvents = false))
|
||||
}
|
||||
|
||||
override fun onReportMessage(eventId: EventId, senderId: UserId) {
|
||||
|
|
@ -220,12 +231,10 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
}
|
||||
|
||||
override fun onViewAllPinnedEvents() {
|
||||
Timber.d("On View All Pinned Events not implemented yet.")
|
||||
backstack.push(NavTarget.PinnedMessagesList)
|
||||
}
|
||||
}
|
||||
val inputs = MessagesNode.Inputs(
|
||||
focusedEventId = inputs.focusedEventId,
|
||||
)
|
||||
val inputs = MessagesNode.Inputs(focusedEventId = navTarget.focusedEventId)
|
||||
createNode<MessagesNode>(buildContext, listOf(callback, inputs))
|
||||
}
|
||||
is NavTarget.MediaViewer -> {
|
||||
|
|
@ -251,7 +260,12 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
createNode<EventDebugInfoNode>(buildContext, listOf(inputs))
|
||||
}
|
||||
is NavTarget.ForwardEvent -> {
|
||||
val inputs = ForwardMessagesNode.Inputs(navTarget.eventId)
|
||||
val timelineProvider = if (navTarget.fromPinnedEvents) {
|
||||
pinnedEventsTimelineProvider
|
||||
} else {
|
||||
timelineController
|
||||
}
|
||||
val inputs = ForwardMessagesNode.Inputs(navTarget.eventId, timelineProvider)
|
||||
val callback = object : ForwardMessagesNode.Callback {
|
||||
override fun onForwardedToSingleRoom(roomId: RoomId) {
|
||||
callbacks.forEach { it.onForwardedToSingleRoom(roomId) }
|
||||
|
|
@ -276,6 +290,38 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
.params(CreatePollEntryPoint.Params(mode = CreatePollMode.EditPoll(eventId = navTarget.eventId)))
|
||||
.build()
|
||||
}
|
||||
NavTarget.PinnedMessagesList -> {
|
||||
val callback = object : PinnedMessagesListNode.Callback {
|
||||
override fun onEventClick(event: TimelineItem.Event) {
|
||||
processEventClick(event)
|
||||
}
|
||||
|
||||
override fun onUserDataClick(userId: UserId) {
|
||||
callbacks.forEach { it.onUserDataClick(userId) }
|
||||
}
|
||||
|
||||
override fun onViewInTimelineClick(eventId: EventId) {
|
||||
val permalinkData = PermalinkData.RoomLink(
|
||||
roomIdOrAlias = room.roomId.toRoomIdOrAlias(),
|
||||
eventId = eventId,
|
||||
)
|
||||
callbacks.forEach { it.onPermalinkClick(permalinkData, pushToBackstack = false) }
|
||||
}
|
||||
|
||||
override fun onRoomPermalinkClick(data: PermalinkData.RoomLink) {
|
||||
callbacks.forEach { it.onPermalinkClick(data, pushToBackstack = !room.matches(data.roomIdOrAlias)) }
|
||||
}
|
||||
|
||||
override fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
|
||||
backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo))
|
||||
}
|
||||
|
||||
override fun onForwardEventClick(eventId: EventId) {
|
||||
backstack.push(NavTarget.ForwardEvent(eventId = eventId, fromPinnedEvents = true))
|
||||
}
|
||||
}
|
||||
createNode<PinnedMessagesListNode>(buildContext, plugins = listOf(callback))
|
||||
}
|
||||
NavTarget.Empty -> {
|
||||
node(buildContext) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@ import dagger.assisted.AssistedInject
|
|||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.features.messages.impl.attachments.Attachment
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
|
||||
import io.element.android.features.messages.impl.timeline.TimelineController
|
||||
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
||||
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
|
||||
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
|
||||
|
|
@ -75,7 +74,6 @@ class MessagesNode @AssistedInject constructor(
|
|||
private val permalinkParser: PermalinkParser,
|
||||
@ApplicationContext
|
||||
private val context: Context,
|
||||
private val timelineController: TimelineController,
|
||||
) : Node(buildContext, plugins = plugins), MessagesNavigator {
|
||||
private val presenter = presenterFactory.create(this)
|
||||
private val callbacks = plugins<Callback>()
|
||||
|
|
@ -107,7 +105,6 @@ class MessagesNode @AssistedInject constructor(
|
|||
analyticsService.capture(room.toAnalyticsViewRoom())
|
||||
},
|
||||
onDestroy = {
|
||||
timelineController.close()
|
||||
mediaPlayer.close()
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import io.element.android.features.messages.api.timeline.HtmlConverterProvider
|
|||
import io.element.android.features.messages.impl.actionlist.ActionListEvents
|
||||
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
|
||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionPostProcessor
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
||||
|
|
@ -98,7 +99,7 @@ class MessagesPresenter @AssistedInject constructor(
|
|||
private val voiceMessageComposerPresenter: VoiceMessageComposerPresenter,
|
||||
timelinePresenterFactory: TimelinePresenter.Factory,
|
||||
private val typingNotificationPresenter: TypingNotificationPresenter,
|
||||
private val actionListPresenter: ActionListPresenter,
|
||||
private val actionListPresenterFactory: ActionListPresenter.Factory,
|
||||
private val customReactionPresenter: CustomReactionPresenter,
|
||||
private val reactionSummaryPresenter: ReactionSummaryPresenter,
|
||||
private val readReceiptBottomSheetPresenter: ReadReceiptBottomSheetPresenter,
|
||||
|
|
@ -114,6 +115,7 @@ class MessagesPresenter @AssistedInject constructor(
|
|||
private val permalinkParser: PermalinkParser,
|
||||
) : Presenter<MessagesState> {
|
||||
private val timelinePresenter = timelinePresenterFactory.create(navigator = navigator)
|
||||
private val actionListPresenter = actionListPresenterFactory.create(TimelineItemActionPostProcessor.Default)
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
|
|
@ -286,6 +288,7 @@ class MessagesPresenter @AssistedInject constructor(
|
|||
TimelineItemAction.EndPoll -> handleEndPollAction(targetEvent, timelineState)
|
||||
TimelineItemAction.Pin -> handlePinAction(targetEvent)
|
||||
TimelineItemAction.Unpin -> handleUnpinAction(targetEvent)
|
||||
TimelineItemAction.ViewInTimeline -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,8 +23,14 @@ import androidx.compose.runtime.getValue
|
|||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.features.messages.api.pinned.IsPinnedMessagesFeatureEnabled
|
||||
import io.element.android.features.messages.impl.UserEventPermissions
|
||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionPostProcessor
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemCallNotifyContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
||||
|
|
@ -36,8 +42,7 @@ import io.element.android.features.messages.impl.timeline.model.event.canBeCopie
|
|||
import io.element.android.features.messages.impl.timeline.model.event.canBeForwarded
|
||||
import io.element.android.features.messages.impl.timeline.model.event.canReact
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||
|
|
@ -47,13 +52,26 @@ import kotlinx.collections.immutable.toImmutableList
|
|||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class ActionListPresenter @Inject constructor(
|
||||
interface ActionListPresenter : Presenter<ActionListState> {
|
||||
interface Factory {
|
||||
fun create(postProcessor: TimelineItemActionPostProcessor): ActionListPresenter
|
||||
}
|
||||
}
|
||||
|
||||
class DefaultActionListPresenter @AssistedInject constructor(
|
||||
@Assisted
|
||||
private val postProcessor: TimelineItemActionPostProcessor,
|
||||
private val appPreferencesStore: AppPreferencesStore,
|
||||
private val featureFlagsService: FeatureFlagService,
|
||||
private val isPinnedMessagesFeatureEnabled: IsPinnedMessagesFeatureEnabled,
|
||||
private val room: MatrixRoom,
|
||||
) : Presenter<ActionListState> {
|
||||
) : ActionListPresenter {
|
||||
@AssistedFactory
|
||||
@ContributesBinding(RoomScope::class)
|
||||
interface Factory : ActionListPresenter.Factory {
|
||||
override fun create(postProcessor: TimelineItemActionPostProcessor): DefaultActionListPresenter
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun present(): ActionListState {
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
|
|
@ -63,7 +81,7 @@ class ActionListPresenter @Inject constructor(
|
|||
}
|
||||
|
||||
val isDeveloperModeEnabled by appPreferencesStore.isDeveloperModeEnabledFlow().collectAsState(initial = false)
|
||||
val isPinnedEventsEnabled by featureFlagsService.isFeatureEnabledFlow(FeatureFlags.PinnedEvents).collectAsState(initial = false)
|
||||
val isPinnedEventsEnabled = isPinnedMessagesFeatureEnabled()
|
||||
val pinnedEventIds by remember {
|
||||
room.roomInfoFlow.map { it.pinnedEventIds }
|
||||
}.collectAsState(initial = persistentListOf())
|
||||
|
|
@ -105,6 +123,7 @@ class ActionListPresenter @Inject constructor(
|
|||
isPinnedEventsEnabled = isPinnedEventsEnabled,
|
||||
isEventPinned = pinnedEventIds.contains(timelineItem.eventId),
|
||||
)
|
||||
|
||||
val displayEmojiReactions = usersEventPermissions.canSendReaction &&
|
||||
timelineItem.content.canReact()
|
||||
if (actions.isNotEmpty() || displayEmojiReactions) {
|
||||
|
|
@ -117,57 +136,59 @@ class ActionListPresenter @Inject constructor(
|
|||
target.value = ActionListState.Target.None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildActions(
|
||||
timelineItem: TimelineItem.Event,
|
||||
usersEventPermissions: UserEventPermissions,
|
||||
isDeveloperModeEnabled: Boolean,
|
||||
isPinnedEventsEnabled: Boolean,
|
||||
isEventPinned: Boolean,
|
||||
): List<TimelineItemAction> {
|
||||
val canRedact = timelineItem.isMine && usersEventPermissions.canRedactOwn || !timelineItem.isMine && usersEventPermissions.canRedactOther
|
||||
return buildList {
|
||||
if (timelineItem.canBeRepliedTo && usersEventPermissions.canSendMessage) {
|
||||
if (timelineItem.isThreaded) {
|
||||
add(TimelineItemAction.ReplyInThread)
|
||||
} else {
|
||||
add(TimelineItemAction.Reply)
|
||||
private fun buildActions(
|
||||
timelineItem: TimelineItem.Event,
|
||||
usersEventPermissions: UserEventPermissions,
|
||||
isDeveloperModeEnabled: Boolean,
|
||||
isPinnedEventsEnabled: Boolean,
|
||||
isEventPinned: Boolean,
|
||||
): List<TimelineItemAction> {
|
||||
val canRedact = timelineItem.isMine && usersEventPermissions.canRedactOwn || !timelineItem.isMine && usersEventPermissions.canRedactOther
|
||||
return buildList {
|
||||
if (timelineItem.canBeRepliedTo && usersEventPermissions.canSendMessage) {
|
||||
if (timelineItem.isThreaded) {
|
||||
add(TimelineItemAction.ReplyInThread)
|
||||
} else {
|
||||
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()) {
|
||||
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)
|
||||
}
|
||||
}.postFilter(timelineItem.content)
|
||||
.postFilter(timelineItem.content)
|
||||
.let(postProcessor::process)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ sealed class TimelineItemAction(
|
|||
@DrawableRes val icon: Int,
|
||||
val destructive: Boolean = false
|
||||
) {
|
||||
data object ViewInTimeline : TimelineItemAction(CommonStrings.action_view_in_timeline, CompoundDrawables.ic_compound_visibility_on)
|
||||
data object Forward : TimelineItemAction(CommonStrings.action_forward, CompoundDrawables.ic_compound_forward)
|
||||
data object Copy : TimelineItemAction(CommonStrings.action_copy, CompoundDrawables.ic_compound_copy)
|
||||
data object CopyLink : TimelineItemAction(CommonStrings.action_copy_link_to_message, CompoundDrawables.ic_compound_link)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.actionlist.model
|
||||
|
||||
fun interface TimelineItemActionPostProcessor {
|
||||
fun process(actions: List<TimelineItemAction>): List<TimelineItemAction>
|
||||
|
||||
object Default : TimelineItemActionPostProcessor {
|
||||
override fun process(actions: List<TimelineItemAction>): List<TimelineItemAction> {
|
||||
return actions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -34,6 +34,7 @@ import io.element.android.libraries.architecture.inputs
|
|||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
|
||||
import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint
|
||||
import io.element.android.libraries.roomselect.api.RoomSelectMode
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
|
@ -59,10 +60,13 @@ class ForwardMessagesNode @AssistedInject constructor(
|
|||
fun onForwardedToSingleRoom(roomId: RoomId)
|
||||
}
|
||||
|
||||
data class Inputs(val eventId: EventId) : NodeInputs
|
||||
data class Inputs(
|
||||
val eventId: EventId,
|
||||
val timelineProvider: TimelineProvider,
|
||||
) : NodeInputs
|
||||
|
||||
private val inputs = inputs<Inputs>()
|
||||
private val presenter = presenterFactory.create(inputs.eventId.value)
|
||||
private val presenter = presenterFactory.create(inputs.eventId.value, inputs.timelineProvider)
|
||||
private val callbacks = plugins.filterIsInstance<Callback>()
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
|
|
|
|||
|
|
@ -36,14 +36,14 @@ import kotlinx.coroutines.launch
|
|||
|
||||
class ForwardMessagesPresenter @AssistedInject constructor(
|
||||
@Assisted eventId: String,
|
||||
@Assisted private val timelineProvider: TimelineProvider,
|
||||
private val appCoroutineScope: CoroutineScope,
|
||||
private val timelineProvider: TimelineProvider,
|
||||
) : Presenter<ForwardMessagesState> {
|
||||
private val eventId: EventId = EventId(eventId)
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(eventId: String): ForwardMessagesPresenter
|
||||
fun create(eventId: String, timelineProvider: TimelineProvider): ForwardMessagesPresenter
|
||||
}
|
||||
|
||||
private val forwardingActionState: MutableState<AsyncAction<List<RoomId>>> = mutableStateOf(AsyncAction.Uninitialized)
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import androidx.compose.runtime.mutableStateOf
|
|||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.features.messages.api.pinned.IsPinnedMessagesFeatureEnabled
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
|
|
@ -30,11 +31,6 @@ import kotlinx.coroutines.flow.launchIn
|
|||
import kotlinx.coroutines.flow.onEach
|
||||
import javax.inject.Inject
|
||||
|
||||
fun interface IsPinnedMessagesFeatureEnabled {
|
||||
@Composable
|
||||
operator fun invoke(): Boolean
|
||||
}
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultIsPinnedMessagesFeatureEnabled @Inject constructor(
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.pinned
|
||||
|
||||
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.core.coroutine.mapState
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.di.SingleIn
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import javax.inject.Inject
|
||||
|
||||
@SingleIn(RoomScope::class)
|
||||
class PinnedEventsTimelineProvider @Inject constructor(
|
||||
private val room: MatrixRoom,
|
||||
private val networkMonitor: NetworkMonitor,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
) : TimelineProvider {
|
||||
private val _timelineStateFlow: MutableStateFlow<AsyncData<Timeline>> = MutableStateFlow(AsyncData.Uninitialized)
|
||||
|
||||
override fun activeTimelineFlow(): StateFlow<Timeline?> {
|
||||
return _timelineStateFlow
|
||||
.mapState { value ->
|
||||
value.dataOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
val timelineStateFlow = _timelineStateFlow
|
||||
|
||||
fun launchIn(scope: CoroutineScope) {
|
||||
combine(
|
||||
featureFlagService.isFeatureEnabledFlow(FeatureFlags.PinnedEvents),
|
||||
networkMonitor.connectivity
|
||||
) {
|
||||
// do not use connectivity here as data can be loaded from cache, it's just to trigger retry if needed
|
||||
isEnabled, _ ->
|
||||
isEnabled
|
||||
}
|
||||
.onEach { isFeatureEnabled ->
|
||||
if (isFeatureEnabled) {
|
||||
loadTimelineIfNeeded()
|
||||
} else {
|
||||
_timelineStateFlow.value = AsyncData.Uninitialized
|
||||
}
|
||||
}
|
||||
.onCompletion {
|
||||
invokeOnTimeline { close() }
|
||||
}
|
||||
.launchIn(scope)
|
||||
}
|
||||
|
||||
suspend fun invokeOnTimeline(action: suspend Timeline.() -> Unit) {
|
||||
when (val asyncTimeline = timelineStateFlow.value) {
|
||||
is AsyncData.Success -> action(asyncTimeline.data)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadTimelineIfNeeded() {
|
||||
when (timelineStateFlow.value) {
|
||||
is AsyncData.Uninitialized, is AsyncData.Failure -> {
|
||||
timelineStateFlow.emit(AsyncData.Loading())
|
||||
room.pinnedEventsTimeline()
|
||||
.fold(
|
||||
{ timelineStateFlow.emit(AsyncData.Success(it)) },
|
||||
{ timelineStateFlow.emit(AsyncData.Failure(it)) }
|
||||
)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -26,18 +26,19 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import io.element.android.features.messages.impl.pinned.IsPinnedMessagesFeatureEnabled
|
||||
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
||||
import io.element.android.features.messages.impl.pinned.PinnedEventsTimelineProvider
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
|
@ -45,46 +46,38 @@ import kotlin.time.Duration.Companion.milliseconds
|
|||
class PinnedMessagesBannerPresenter @Inject constructor(
|
||||
private val room: MatrixRoom,
|
||||
private val itemFactory: PinnedMessagesBannerItemFactory,
|
||||
private val isFeatureEnabled: IsPinnedMessagesFeatureEnabled,
|
||||
private val networkMonitor: NetworkMonitor,
|
||||
private val pinnedEventsTimelineProvider: PinnedEventsTimelineProvider,
|
||||
) : Presenter<PinnedMessagesBannerState> {
|
||||
private val pinnedItems = mutableStateOf<ImmutableList<PinnedMessagesBannerItem>>(persistentListOf())
|
||||
private val pinnedItems = mutableStateOf<AsyncData<ImmutableList<PinnedMessagesBannerItem>>>(AsyncData.Uninitialized)
|
||||
|
||||
@Composable
|
||||
override fun present(): PinnedMessagesBannerState {
|
||||
val isFeatureEnabled = isFeatureEnabled()
|
||||
val expectedPinnedMessagesCount by remember {
|
||||
room.roomInfoFlow.map { roomInfo -> roomInfo.pinnedEventIds.size }
|
||||
}.collectAsState(initial = 0)
|
||||
|
||||
var hasTimelineFailedToLoad by rememberSaveable { mutableStateOf(false) }
|
||||
var currentPinnedMessageIndex by rememberSaveable { mutableIntStateOf(-1) }
|
||||
|
||||
PinnedMessagesBannerItemsEffect(
|
||||
isFeatureEnabled = isFeatureEnabled,
|
||||
onItemsChange = { newItems ->
|
||||
val pinnedMessageCount = newItems.size
|
||||
val pinnedMessageCount = newItems.dataOrNull().orEmpty().size
|
||||
if (currentPinnedMessageIndex >= pinnedMessageCount || currentPinnedMessageIndex < 0) {
|
||||
currentPinnedMessageIndex = pinnedMessageCount - 1
|
||||
}
|
||||
pinnedItems.value = newItems
|
||||
},
|
||||
onTimelineFail = { hasTimelineFailed ->
|
||||
hasTimelineFailedToLoad = hasTimelineFailed
|
||||
}
|
||||
)
|
||||
|
||||
fun handleEvent(event: PinnedMessagesBannerEvents) {
|
||||
when (event) {
|
||||
is PinnedMessagesBannerEvents.MoveToNextPinned -> {
|
||||
currentPinnedMessageIndex = (currentPinnedMessageIndex - 1).mod(pinnedItems.value.size)
|
||||
val loadedCount = pinnedItems.value.dataOrNull().orEmpty().size
|
||||
currentPinnedMessageIndex = (currentPinnedMessageIndex - 1).mod(loadedCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pinnedMessagesBannerState(
|
||||
isFeatureEnabled = isFeatureEnabled,
|
||||
hasTimelineFailed = hasTimelineFailedToLoad,
|
||||
expectedPinnedMessagesCount = expectedPinnedMessagesCount,
|
||||
pinnedItems = pinnedItems.value,
|
||||
currentPinnedMessageIndex = currentPinnedMessageIndex,
|
||||
|
|
@ -94,63 +87,65 @@ class PinnedMessagesBannerPresenter @Inject constructor(
|
|||
|
||||
@Composable
|
||||
private fun pinnedMessagesBannerState(
|
||||
isFeatureEnabled: Boolean,
|
||||
hasTimelineFailed: Boolean,
|
||||
expectedPinnedMessagesCount: Int,
|
||||
pinnedItems: ImmutableList<PinnedMessagesBannerItem>,
|
||||
pinnedItems: AsyncData<ImmutableList<PinnedMessagesBannerItem>>,
|
||||
currentPinnedMessageIndex: Int,
|
||||
eventSink: (PinnedMessagesBannerEvents) -> Unit
|
||||
): PinnedMessagesBannerState {
|
||||
val currentPinnedMessage = pinnedItems.getOrNull(currentPinnedMessageIndex)
|
||||
return when {
|
||||
!isFeatureEnabled -> PinnedMessagesBannerState.Hidden
|
||||
hasTimelineFailed -> PinnedMessagesBannerState.Hidden
|
||||
currentPinnedMessage != null -> PinnedMessagesBannerState.Loaded(
|
||||
currentPinnedMessage = currentPinnedMessage,
|
||||
currentPinnedMessageIndex = currentPinnedMessageIndex,
|
||||
loadedPinnedMessagesCount = pinnedItems.size,
|
||||
eventSink = eventSink
|
||||
)
|
||||
expectedPinnedMessagesCount == 0 -> PinnedMessagesBannerState.Hidden
|
||||
else -> PinnedMessagesBannerState.Loading(expectedPinnedMessagesCount = expectedPinnedMessagesCount)
|
||||
return when (pinnedItems) {
|
||||
is AsyncData.Failure, is AsyncData.Uninitialized -> PinnedMessagesBannerState.Hidden
|
||||
is AsyncData.Loading -> {
|
||||
if (expectedPinnedMessagesCount == 0) {
|
||||
PinnedMessagesBannerState.Hidden
|
||||
} else {
|
||||
PinnedMessagesBannerState.Loading(expectedPinnedMessagesCount = expectedPinnedMessagesCount)
|
||||
}
|
||||
}
|
||||
is AsyncData.Success -> {
|
||||
val currentPinnedMessage = pinnedItems.data.getOrNull(currentPinnedMessageIndex)
|
||||
if (currentPinnedMessage == null) {
|
||||
PinnedMessagesBannerState.Hidden
|
||||
} else {
|
||||
PinnedMessagesBannerState.Loaded(
|
||||
loadedPinnedMessagesCount = pinnedItems.data.size,
|
||||
currentPinnedMessageIndex = currentPinnedMessageIndex,
|
||||
currentPinnedMessage = currentPinnedMessage,
|
||||
eventSink = eventSink
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
|
||||
@Composable
|
||||
private fun PinnedMessagesBannerItemsEffect(
|
||||
isFeatureEnabled: Boolean,
|
||||
onItemsChange: (ImmutableList<PinnedMessagesBannerItem>) -> Unit,
|
||||
onTimelineFail: (Boolean) -> Unit,
|
||||
onItemsChange: (AsyncData<ImmutableList<PinnedMessagesBannerItem>>) -> Unit,
|
||||
) {
|
||||
val updatedOnItemsChange by rememberUpdatedState(onItemsChange)
|
||||
val updatedOnTimelineFail by rememberUpdatedState(onTimelineFail)
|
||||
val networkStatus by networkMonitor.connectivity.collectAsState()
|
||||
LaunchedEffect(Unit) {
|
||||
pinnedEventsTimelineProvider.timelineStateFlow
|
||||
.flatMapLatest { asyncTimeline ->
|
||||
when (asyncTimeline) {
|
||||
AsyncData.Uninitialized -> flowOf(AsyncData.Uninitialized)
|
||||
is AsyncData.Failure -> flowOf(AsyncData.Failure(asyncTimeline.error))
|
||||
is AsyncData.Loading -> flowOf(AsyncData.Loading())
|
||||
is AsyncData.Success -> {
|
||||
asyncTimeline.data.timelineItems
|
||||
.debounce(300.milliseconds)
|
||||
.map { timelineItems ->
|
||||
val pinnedItems = timelineItems.mapNotNull { timelineItem ->
|
||||
itemFactory.create(timelineItem)
|
||||
}.toImmutableList()
|
||||
|
||||
LaunchedEffect(isFeatureEnabled, networkStatus) {
|
||||
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()
|
||||
AsyncData.Success(pinnedItems)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onEach { newItems ->
|
||||
updatedOnItemsChange(newItems)
|
||||
}
|
||||
.onCompletion {
|
||||
pinnedEventsTimeline.close()
|
||||
}
|
||||
.launchIn(this)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.pinned.list
|
||||
|
||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
|
||||
sealed interface PinnedMessagesListEvents {
|
||||
data class HandleAction(val action: TimelineItemAction, val event: TimelineItem.Event) : PinnedMessagesListEvents
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.pinned.list
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||
|
||||
interface PinnedMessagesListNavigator {
|
||||
fun onViewInTimelineClick(eventId: EventId)
|
||||
fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo)
|
||||
fun onForwardEventClick(eventId: EventId)
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.pinned.list
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.bumble.appyx.core.plugin.plugins
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
|
||||
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.libraries.androidutils.system.openUrlInExternalApp
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||
|
||||
@ContributesNode(RoomScope::class)
|
||||
class PinnedMessagesListNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
presenterFactory: PinnedMessagesListPresenter.Factory,
|
||||
private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
|
||||
private val permalinkParser: PermalinkParser,
|
||||
) : Node(buildContext, plugins = plugins), PinnedMessagesListNavigator {
|
||||
interface Callback : Plugin {
|
||||
fun onEventClick(event: TimelineItem.Event)
|
||||
fun onUserDataClick(userId: UserId)
|
||||
fun onViewInTimelineClick(eventId: EventId)
|
||||
fun onRoomPermalinkClick(data: PermalinkData.RoomLink)
|
||||
fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo)
|
||||
fun onForwardEventClick(eventId: EventId)
|
||||
}
|
||||
|
||||
private val presenter = presenterFactory.create(this)
|
||||
private val callbacks = plugins<Callback>()
|
||||
|
||||
private fun onEventClick(event: TimelineItem.Event) {
|
||||
return callbacks.forEach { it.onEventClick(event) }
|
||||
}
|
||||
|
||||
private fun onUserDataClick(userId: UserId) {
|
||||
callbacks.forEach { it.onUserDataClick(userId) }
|
||||
}
|
||||
|
||||
private fun onLinkClick(context: Context, url: String) {
|
||||
when (val permalink = permalinkParser.parse(url)) {
|
||||
is PermalinkData.UserLink -> {
|
||||
// Open the room member profile, it will fallback to
|
||||
// the user profile if the user is not in the room
|
||||
callbacks.forEach { it.onUserDataClick(permalink.userId) }
|
||||
}
|
||||
is PermalinkData.RoomLink -> {
|
||||
callbacks.forEach { it.onRoomPermalinkClick(permalink) }
|
||||
}
|
||||
is PermalinkData.FallbackLink,
|
||||
is PermalinkData.RoomEmailInviteLink -> {
|
||||
context.openUrlInExternalApp(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewInTimelineClick(eventId: EventId) {
|
||||
callbacks.forEach { it.onViewInTimelineClick(eventId) }
|
||||
}
|
||||
|
||||
override fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
|
||||
callbacks.forEach { it.onShowEventDebugInfoClick(eventId, debugInfo) }
|
||||
}
|
||||
|
||||
override fun onForwardEventClick(eventId: EventId) {
|
||||
callbacks.forEach { it.onForwardEventClick(eventId) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
CompositionLocalProvider(
|
||||
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val state = presenter.present()
|
||||
PinnedMessagesListView(
|
||||
state = state,
|
||||
onBackClick = ::navigateUp,
|
||||
onEventClick = ::onEventClick,
|
||||
onUserDataClick = ::onUserDataClick,
|
||||
onLinkClick = { url -> onLinkClick(context, url) },
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,234 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.pinned.list
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.features.messages.impl.UserEventPermissions
|
||||
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
|
||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||
import io.element.android.features.messages.impl.pinned.PinnedEventsTimelineProvider
|
||||
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
|
||||
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.isDm
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.canPinUnpin
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn
|
||||
import io.element.android.libraries.matrix.api.room.roomMembers
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class PinnedMessagesListPresenter @AssistedInject constructor(
|
||||
@Assisted private val navigator: PinnedMessagesListNavigator,
|
||||
private val room: MatrixRoom,
|
||||
private val timelineItemsFactory: TimelineItemsFactory,
|
||||
private val timelineProvider: PinnedEventsTimelineProvider,
|
||||
private val snackbarDispatcher: SnackbarDispatcher,
|
||||
actionListPresenterFactory: ActionListPresenter.Factory,
|
||||
) : Presenter<PinnedMessagesListState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(navigator: PinnedMessagesListNavigator): PinnedMessagesListPresenter
|
||||
}
|
||||
|
||||
private val actionListPresenter = actionListPresenterFactory.create(PinnedMessagesListTimelineActionPostProcessor())
|
||||
|
||||
@Composable
|
||||
override fun present(): PinnedMessagesListState {
|
||||
val timelineRoomInfo = remember {
|
||||
TimelineRoomInfo(
|
||||
isDm = room.isDm,
|
||||
name = room.displayName,
|
||||
// We don't need to compute those values
|
||||
userHasPermissionToSendMessage = false,
|
||||
userHasPermissionToSendReaction = false,
|
||||
isCallOngoing = false,
|
||||
)
|
||||
}
|
||||
|
||||
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
|
||||
val userEventPermissions by userEventPermissions(syncUpdateFlow.value)
|
||||
|
||||
var pinnedMessageItems by remember {
|
||||
mutableStateOf<AsyncData<ImmutableList<TimelineItem>>>(AsyncData.Uninitialized)
|
||||
}
|
||||
|
||||
PinnedMessagesListEffect(
|
||||
onItemsChange = { newItems ->
|
||||
pinnedMessageItems = newItems
|
||||
}
|
||||
)
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
fun handleEvents(event: PinnedMessagesListEvents) {
|
||||
when (event) {
|
||||
is PinnedMessagesListEvents.HandleAction -> coroutineScope.handleTimelineAction(event.action, event.event)
|
||||
}
|
||||
}
|
||||
|
||||
return pinnedMessagesListState(
|
||||
timelineRoomInfo = timelineRoomInfo,
|
||||
userEventPermissions = userEventPermissions,
|
||||
timelineItems = pinnedMessageItems,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.handleTimelineAction(
|
||||
action: TimelineItemAction,
|
||||
targetEvent: TimelineItem.Event,
|
||||
) = launch {
|
||||
when (action) {
|
||||
TimelineItemAction.Redact -> handleActionRedact(targetEvent)
|
||||
TimelineItemAction.ViewSource -> {
|
||||
navigator.onShowEventDebugInfoClick(targetEvent.eventId, targetEvent.debugInfo)
|
||||
}
|
||||
TimelineItemAction.Forward -> {
|
||||
targetEvent.eventId?.let { eventId ->
|
||||
navigator.onForwardEventClick(eventId)
|
||||
}
|
||||
}
|
||||
TimelineItemAction.Unpin -> handleUnpinAction(targetEvent)
|
||||
TimelineItemAction.ViewInTimeline -> {
|
||||
targetEvent.eventId?.let { eventId ->
|
||||
navigator.onViewInTimelineClick(eventId)
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleUnpinAction(targetEvent: TimelineItem.Event) {
|
||||
if (targetEvent.eventId == null) return
|
||||
timelineProvider.invokeOnTimeline {
|
||||
unpinEvent(targetEvent.eventId)
|
||||
.onFailure {
|
||||
Timber.e(it, "Failed to unpin event ${targetEvent.eventId}")
|
||||
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleActionRedact(event: TimelineItem.Event) {
|
||||
timelineProvider.invokeOnTimeline {
|
||||
redactEvent(eventId = event.eventId, transactionId = event.transactionId, reason = null)
|
||||
.onFailure { Timber.e(it) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun userEventPermissions(updateKey: Long): State<UserEventPermissions> {
|
||||
return produceState(UserEventPermissions.DEFAULT, key1 = updateKey) {
|
||||
value = UserEventPermissions(
|
||||
canSendMessage = false,
|
||||
canSendReaction = false,
|
||||
canRedactOwn = room.canRedactOwn().getOrElse { false },
|
||||
canRedactOther = room.canRedactOther().getOrElse { false },
|
||||
canPinUnpin = room.canPinUnpin().getOrElse { false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
@Composable
|
||||
private fun PinnedMessagesListEffect(onItemsChange: (AsyncData<ImmutableList<TimelineItem>>) -> Unit) {
|
||||
val updatedOnItemsChange by rememberUpdatedState(onItemsChange)
|
||||
|
||||
val timelineState by timelineProvider.timelineStateFlow.collectAsState()
|
||||
|
||||
LaunchedEffect(timelineState) {
|
||||
when (val asyncTimeline = timelineState) {
|
||||
AsyncData.Uninitialized -> flowOf(AsyncData.Uninitialized)
|
||||
is AsyncData.Failure -> flowOf(AsyncData.Failure(asyncTimeline.error))
|
||||
is AsyncData.Loading -> flowOf(AsyncData.Loading())
|
||||
is AsyncData.Success -> {
|
||||
val timelineItemsFlow = asyncTimeline.data.timelineItems.debounce(300.milliseconds)
|
||||
combine(timelineItemsFlow, room.membersStateFlow) { items, membersState ->
|
||||
timelineItemsFactory.replaceWith(
|
||||
timelineItems = items,
|
||||
roomMembers = membersState.roomMembers().orEmpty()
|
||||
)
|
||||
}.launchIn(this)
|
||||
|
||||
timelineItemsFactory.timelineItems.map { timelineItems ->
|
||||
AsyncData.Success(timelineItems)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onEach { items ->
|
||||
updatedOnItemsChange(items)
|
||||
}
|
||||
.launchIn(this)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun pinnedMessagesListState(
|
||||
timelineRoomInfo: TimelineRoomInfo,
|
||||
userEventPermissions: UserEventPermissions,
|
||||
timelineItems: AsyncData<ImmutableList<TimelineItem>>,
|
||||
eventSink: (PinnedMessagesListEvents) -> Unit
|
||||
): PinnedMessagesListState {
|
||||
return when (timelineItems) {
|
||||
AsyncData.Uninitialized, is AsyncData.Loading -> PinnedMessagesListState.Loading
|
||||
is AsyncData.Failure -> PinnedMessagesListState.Failed
|
||||
is AsyncData.Success -> {
|
||||
if (timelineItems.data.isEmpty()) {
|
||||
PinnedMessagesListState.Empty
|
||||
} else {
|
||||
val actionListState = actionListPresenter.present()
|
||||
PinnedMessagesListState.Filled(
|
||||
timelineRoomInfo = timelineRoomInfo,
|
||||
userEventPermissions = userEventPermissions,
|
||||
timelineItems = timelineItems.data,
|
||||
actionListState = actionListState,
|
||||
eventSink = eventSink
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.pinned.list
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import io.element.android.features.messages.impl.UserEventPermissions
|
||||
import io.element.android.features.messages.impl.actionlist.ActionListState
|
||||
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.libraries.ui.strings.CommonPlurals
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@Immutable
|
||||
sealed interface PinnedMessagesListState {
|
||||
data object Failed : PinnedMessagesListState
|
||||
data object Loading : PinnedMessagesListState
|
||||
data object Empty : PinnedMessagesListState
|
||||
data class Filled(
|
||||
val timelineRoomInfo: TimelineRoomInfo,
|
||||
val userEventPermissions: UserEventPermissions,
|
||||
val timelineItems: ImmutableList<TimelineItem>,
|
||||
val actionListState: ActionListState,
|
||||
val eventSink: (PinnedMessagesListEvents) -> Unit,
|
||||
) : PinnedMessagesListState {
|
||||
val loadedPinnedMessagesCount = timelineItems.count { timelineItem -> timelineItem is TimelineItem.Event }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun title(): String {
|
||||
return when (this) {
|
||||
is Filled -> {
|
||||
pluralStringResource(id = CommonPlurals.screen_pinned_timeline_screen_title, loadedPinnedMessagesCount, loadedPinnedMessagesCount)
|
||||
}
|
||||
else -> stringResource(id = CommonStrings.screen_pinned_timeline_screen_title_empty)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.pinned.list
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.messages.impl.UserEventPermissions
|
||||
import io.element.android.features.messages.impl.actionlist.ActionListState
|
||||
import io.element.android.features.messages.impl.actionlist.anActionListState
|
||||
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemDaySeparator
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemAudioContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemFileContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
open class PinnedMessagesListStateProvider : PreviewParameterProvider<PinnedMessagesListState> {
|
||||
override val values: Sequence<PinnedMessagesListState>
|
||||
get() = sequenceOf(
|
||||
aFailedPinnedMessagesListState(),
|
||||
aLoadingPinnedMessagesListState(),
|
||||
anEmptyPinnedMessagesListState(),
|
||||
aLoadedPinnedMessagesListState(
|
||||
timelineItems = persistentListOf(
|
||||
aTimelineItemEvent(
|
||||
isMine = false,
|
||||
content = aTimelineItemTextContent("A pinned message"),
|
||||
groupPosition = TimelineItemGroupPosition.Last,
|
||||
timelineItemReactions = aTimelineItemReactions(0)
|
||||
),
|
||||
aTimelineItemEvent(
|
||||
isMine = false,
|
||||
content = aTimelineItemAudioContent("A pinned file"),
|
||||
groupPosition = TimelineItemGroupPosition.Middle,
|
||||
timelineItemReactions = aTimelineItemReactions(0)
|
||||
),
|
||||
aTimelineItemEvent(
|
||||
isMine = false,
|
||||
content = aTimelineItemPollContent("A pinned poll?"),
|
||||
groupPosition = TimelineItemGroupPosition.First,
|
||||
timelineItemReactions = aTimelineItemReactions(0)
|
||||
),
|
||||
aTimelineItemDaySeparator(),
|
||||
aTimelineItemEvent(
|
||||
isMine = true,
|
||||
content = aTimelineItemTextContent("A pinned message"),
|
||||
groupPosition = TimelineItemGroupPosition.Last,
|
||||
timelineItemReactions = aTimelineItemReactions(0)
|
||||
),
|
||||
aTimelineItemEvent(
|
||||
isMine = true,
|
||||
content = aTimelineItemFileContent("A pinned file?"),
|
||||
groupPosition = TimelineItemGroupPosition.Middle,
|
||||
timelineItemReactions = aTimelineItemReactions(0)
|
||||
),
|
||||
aTimelineItemEvent(
|
||||
isMine = true,
|
||||
content = aTimelineItemPollContent("A pinned poll?"),
|
||||
groupPosition = TimelineItemGroupPosition.First,
|
||||
timelineItemReactions = aTimelineItemReactions(0)
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun aFailedPinnedMessagesListState() = PinnedMessagesListState.Failed
|
||||
|
||||
fun aLoadingPinnedMessagesListState() = PinnedMessagesListState.Loading
|
||||
|
||||
fun anEmptyPinnedMessagesListState() = PinnedMessagesListState.Empty
|
||||
|
||||
fun aLoadedPinnedMessagesListState(
|
||||
timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(),
|
||||
timelineItems: List<TimelineItem> = emptyList(),
|
||||
actionListState: ActionListState = anActionListState(),
|
||||
aUserEventPermissions: UserEventPermissions = UserEventPermissions.DEFAULT,
|
||||
eventSink: (PinnedMessagesListEvents) -> Unit = {}
|
||||
) = PinnedMessagesListState.Filled(
|
||||
timelineRoomInfo = timelineRoomInfo,
|
||||
timelineItems = timelineItems.toImmutableList(),
|
||||
actionListState = actionListState,
|
||||
userEventPermissions = aUserEventPermissions,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.pinned.list
|
||||
|
||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionPostProcessor
|
||||
|
||||
class PinnedMessagesListTimelineActionPostProcessor : TimelineItemActionPostProcessor {
|
||||
override fun process(actions: List<TimelineItemAction>): List<TimelineItemAction> {
|
||||
return buildList {
|
||||
add(TimelineItemAction.ViewInTimeline)
|
||||
actions.firstOrNull { it is TimelineItemAction.Unpin }?.let(::add)
|
||||
actions.firstOrNull { it is TimelineItemAction.Forward }?.let(::add)
|
||||
actions.firstOrNull { it is TimelineItemAction.ViewSource }?.let(::add)
|
||||
actions.firstOrNull { it is TimelineItemAction.Redact }?.let(::add)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,272 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.pinned.list
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.messages.impl.actionlist.ActionListEvents
|
||||
import io.element.android.features.messages.impl.actionlist.ActionListView
|
||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||
import io.element.android.features.messages.impl.timeline.components.TimelineItemRow
|
||||
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
|
||||
import io.element.android.features.poll.api.pollcontent.PollTitleView
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
||||
import io.element.android.libraries.designsystem.icons.CompoundDrawables
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
fun PinnedMessagesListView(
|
||||
state: PinnedMessagesListState,
|
||||
onBackClick: () -> Unit,
|
||||
onEventClick: (event: TimelineItem.Event) -> Unit,
|
||||
onUserDataClick: (UserId) -> Unit,
|
||||
onLinkClick: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
PinnedMessagesListTopBar(state, onBackClick)
|
||||
},
|
||||
content = { padding ->
|
||||
PinnedMessagesListContent(
|
||||
state = state,
|
||||
onEventClick = onEventClick,
|
||||
onUserDataClick = onUserDataClick,
|
||||
onLinkClick = onLinkClick,
|
||||
onErrorDismiss = onBackClick,
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
.consumeWindowInsets(padding),
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun PinnedMessagesListTopBar(
|
||||
state: PinnedMessagesListState,
|
||||
onBackClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = state.title(),
|
||||
style = ElementTheme.typography.fontBodyLgMedium
|
||||
)
|
||||
},
|
||||
navigationIcon = { BackButton(onClick = onBackClick) },
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PinnedMessagesListContent(
|
||||
state: PinnedMessagesListState,
|
||||
onEventClick: (event: TimelineItem.Event) -> Unit,
|
||||
onUserDataClick: (UserId) -> Unit,
|
||||
onLinkClick: (String) -> Unit,
|
||||
onErrorDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(modifier.fillMaxSize()) {
|
||||
when (state) {
|
||||
PinnedMessagesListState.Failed -> {
|
||||
ErrorDialog(
|
||||
title = stringResource(id = CommonStrings.error_unknown),
|
||||
content = stringResource(id = CommonStrings.error_failed_loading_messages),
|
||||
onDismiss = onErrorDismiss
|
||||
)
|
||||
}
|
||||
PinnedMessagesListState.Empty -> PinnedMessagesListEmpty()
|
||||
is PinnedMessagesListState.Filled -> PinnedMessagesListLoaded(
|
||||
state = state,
|
||||
onEventClick = onEventClick,
|
||||
onUserDataClick = onUserDataClick,
|
||||
onLinkClick = onLinkClick,
|
||||
)
|
||||
PinnedMessagesListState.Loading -> {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PinnedMessagesListEmpty(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier.padding(
|
||||
horizontal = 32.dp,
|
||||
vertical = 48.dp,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val pinActionText = stringResource(id = CommonStrings.action_pin)
|
||||
IconTitleSubtitleMolecule(
|
||||
title = stringResource(id = CommonStrings.screen_pinned_timeline_empty_state_headline),
|
||||
subTitle = stringResource(id = CommonStrings.screen_pinned_timeline_empty_state_description, pinActionText),
|
||||
iconResourceId = CompoundDrawables.ic_compound_pin,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PinnedMessagesListLoaded(
|
||||
state: PinnedMessagesListState.Filled,
|
||||
onEventClick: (event: TimelineItem.Event) -> Unit,
|
||||
onUserDataClick: (UserId) -> Unit,
|
||||
onLinkClick: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
fun onActionSelected(timelineItemAction: TimelineItemAction, event: TimelineItem.Event) {
|
||||
state.actionListState.eventSink(
|
||||
ActionListEvents.Clear
|
||||
)
|
||||
state.eventSink(
|
||||
PinnedMessagesListEvents.HandleAction(
|
||||
action = timelineItemAction,
|
||||
event = event,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun onMessageLongClick(event: TimelineItem.Event) {
|
||||
state.actionListState.eventSink(
|
||||
ActionListEvents.ComputeForMessage(
|
||||
event = event,
|
||||
userEventPermissions = state.userEventPermissions,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
ActionListView(
|
||||
state = state.actionListState,
|
||||
onSelectAction = ::onActionSelected,
|
||||
onCustomReactionClick = {},
|
||||
onEmojiReactionClick = { _, _ -> },
|
||||
)
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
state = rememberLazyListState(),
|
||||
reverseLayout = true,
|
||||
contentPadding = PaddingValues(vertical = 8.dp),
|
||||
) {
|
||||
items(
|
||||
items = state.timelineItems,
|
||||
contentType = { timelineItem -> timelineItem.contentType() },
|
||||
key = { timelineItem -> timelineItem.identifier() },
|
||||
) { timelineItem ->
|
||||
TimelineItemRow(
|
||||
timelineItem = timelineItem,
|
||||
timelineRoomInfo = state.timelineRoomInfo,
|
||||
renderReadReceipts = false,
|
||||
isLastOutgoingMessage = false,
|
||||
focusedEventId = null,
|
||||
onClick = onEventClick,
|
||||
onLongClick = ::onMessageLongClick,
|
||||
onUserDataClick = onUserDataClick,
|
||||
onLinkClick = onLinkClick,
|
||||
inReplyToClick = {},
|
||||
onReactionClick = { _, _ -> },
|
||||
onReactionLongClick = { _, _ -> },
|
||||
onMoreReactionsClick = {},
|
||||
onReadReceiptClick = {},
|
||||
eventSink = {},
|
||||
onSwipeToReply = {},
|
||||
onJoinCallClick = {},
|
||||
onShieldClick = {},
|
||||
eventContentView = { event, contentModifier, onContentLayoutChange ->
|
||||
TimelineItemEventContentViewWrapper(
|
||||
event = event,
|
||||
onLinkClick = onLinkClick,
|
||||
modifier = contentModifier,
|
||||
onContentLayoutChange = onContentLayoutChange
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TimelineItemEventContentViewWrapper(
|
||||
event: TimelineItem.Event,
|
||||
onLinkClick: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
||||
) {
|
||||
if (event.content is TimelineItemPollContent) {
|
||||
PollTitleView(
|
||||
title = event.content.question,
|
||||
isPollEnded = event.content.isEnded,
|
||||
modifier = modifier
|
||||
)
|
||||
} else {
|
||||
TimelineItemEventContentView(
|
||||
content = event.content,
|
||||
onLinkClick = onLinkClick,
|
||||
eventSink = { },
|
||||
modifier = modifier,
|
||||
onContentLayoutChange = onContentLayoutChange
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun PinnedMessagesListViewPreview(@PreviewParameter(PinnedMessagesListStateProvider::class) state: PinnedMessagesListState) =
|
||||
ElementPreview {
|
||||
PinnedMessagesListView(
|
||||
state = state,
|
||||
onBackClick = {},
|
||||
onEventClick = { },
|
||||
onUserDataClick = {},
|
||||
onLinkClick = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -51,6 +51,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemE
|
|||
import io.element.android.libraries.matrix.ui.room.canSendMessageAsState
|
||||
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.combine
|
||||
|
|
@ -88,7 +89,7 @@ class TimelinePresenter @AssistedInject constructor(
|
|||
|
||||
val lastReadReceiptId = rememberSaveable { mutableStateOf<EventId?>(null) }
|
||||
|
||||
val timelineItems by timelineItemsFactory.collectItemsAsState()
|
||||
val timelineItems by timelineItemsFactory.timelineItems.collectAsState(initial = persistentListOf())
|
||||
val roomInfo by room.roomInfoFlow.collectAsState(initial = null)
|
||||
|
||||
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
|
||||
|
|
|
|||
|
|
@ -129,7 +129,16 @@ fun TimelineItemEventRow(
|
|||
onReadReceiptClick: (event: TimelineItem.Event) -> Unit,
|
||||
onSwipeToReply: () -> Unit,
|
||||
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
eventContentView: @Composable (Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit = { contentModifier, onContentLayoutChange ->
|
||||
TimelineItemEventContentView(
|
||||
content = event.content,
|
||||
onLinkClick = onLinkClick,
|
||||
eventSink = eventSink,
|
||||
modifier = contentModifier,
|
||||
onContentLayoutChange = onContentLayoutChange
|
||||
)
|
||||
},
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
|
@ -188,8 +197,7 @@ fun TimelineItemEventRow(
|
|||
onReactionClick = { emoji -> onReactionClick(emoji, event) },
|
||||
onReactionLongClick = { emoji -> onReactionLongClick(emoji, event) },
|
||||
onMoreReactionsClick = { onMoreReactionsClick(event) },
|
||||
onLinkClick = onLinkClick,
|
||||
eventSink = eventSink,
|
||||
eventContentView = eventContentView,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -207,8 +215,7 @@ fun TimelineItemEventRow(
|
|||
onReactionClick = { emoji -> onReactionClick(emoji, event) },
|
||||
onReactionLongClick = { emoji -> onReactionLongClick(emoji, event) },
|
||||
onMoreReactionsClick = { onMoreReactionsClick(event) },
|
||||
onLinkClick = onLinkClick,
|
||||
eventSink = eventSink,
|
||||
eventContentView = eventContentView,
|
||||
)
|
||||
}
|
||||
// Read receipts / Send state
|
||||
|
|
@ -263,9 +270,8 @@ private fun TimelineItemEventRowContent(
|
|||
onReactionClick: (emoji: String) -> Unit,
|
||||
onReactionLongClick: (emoji: String) -> Unit,
|
||||
onMoreReactionsClick: (event: TimelineItem.Event) -> Unit,
|
||||
onLinkClick: (String) -> Unit,
|
||||
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
eventContentView: @Composable (Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit,
|
||||
) {
|
||||
fun ConstrainScope.linkStartOrEnd(event: TimelineItem.Event) = if (event.isMine) {
|
||||
end.linkTo(parent.end)
|
||||
|
|
@ -328,8 +334,7 @@ private fun TimelineItemEventRowContent(
|
|||
onShieldClick = onShieldClick,
|
||||
onMessageLongClick = onLongClick,
|
||||
inReplyToClick = inReplyToClick,
|
||||
onLinkClick = onLinkClick,
|
||||
eventSink = eventSink,
|
||||
eventContentView = eventContentView,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -389,12 +394,11 @@ private fun MessageEventBubbleContent(
|
|||
onShieldClick: (MessageShield) -> Unit,
|
||||
onMessageLongClick: () -> Unit,
|
||||
inReplyToClick: () -> Unit,
|
||||
onLinkClick: (String) -> Unit,
|
||||
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
|
||||
@SuppressLint("ModifierParameter")
|
||||
// need to rename this modifier to prevent linter false positives
|
||||
@Suppress("ModifierNaming")
|
||||
bubbleModifier: Modifier = Modifier,
|
||||
eventContentView: @Composable (Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit,
|
||||
) {
|
||||
// Long clicks are not not automatically propagated from a `clickable`
|
||||
// to its `combinedClickable` parent so we do it manually
|
||||
|
|
@ -521,15 +525,10 @@ private fun MessageEventBubbleContent(
|
|||
onShieldClick = onShieldClick,
|
||||
canShrinkContent = canShrinkContent,
|
||||
modifier = timestampLayoutModifier,
|
||||
) { onContentLayoutChange ->
|
||||
TimelineItemEventContentView(
|
||||
content = event.content,
|
||||
onLinkClick = onLinkClick,
|
||||
eventSink = eventSink,
|
||||
onContentLayoutChange = onContentLayoutChange,
|
||||
modifier = contentModifier
|
||||
)
|
||||
}
|
||||
content = { onContentLayoutChange ->
|
||||
eventContentView(contentModifier, onContentLayoutChange)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val inReplyTo = @Composable { inReplyTo: InReplyToDetails ->
|
||||
|
|
|
|||
|
|
@ -28,7 +28,9 @@ import io.element.android.features.messages.impl.timeline.TimelineEvents
|
|||
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
|
||||
import io.element.android.features.messages.impl.timeline.aGroupedEvents
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo
|
||||
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
|
||||
import io.element.android.features.messages.impl.timeline.components.group.GroupHeaderView
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState
|
||||
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
|
|
@ -56,7 +58,17 @@ fun TimelineItemGroupedEventsRow(
|
|||
onMoreReactionsClick: (TimelineItem.Event) -> Unit,
|
||||
onReadReceiptClick: (TimelineItem.Event) -> Unit,
|
||||
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit =
|
||||
{ event, contentModifier, onContentLayoutChange ->
|
||||
TimelineItemEventContentView(
|
||||
content = event.content,
|
||||
onLinkClick = onLinkClick,
|
||||
eventSink = eventSink,
|
||||
modifier = contentModifier,
|
||||
onContentLayoutChange = onContentLayoutChange
|
||||
)
|
||||
},
|
||||
) {
|
||||
val isExpanded = rememberSaveable(key = timelineItem.identifier().value) { mutableStateOf(false) }
|
||||
|
||||
|
|
@ -84,6 +96,7 @@ fun TimelineItemGroupedEventsRow(
|
|||
onReadReceiptClick = onReadReceiptClick,
|
||||
eventSink = eventSink,
|
||||
modifier = modifier,
|
||||
eventContentView = eventContentView,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -108,6 +121,16 @@ private fun TimelineItemGroupedEventsRowContent(
|
|||
onReadReceiptClick: (TimelineItem.Event) -> Unit,
|
||||
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit =
|
||||
{ event, contentModifier, onContentLayoutChange ->
|
||||
TimelineItemEventContentView(
|
||||
content = event.content,
|
||||
onLinkClick = onLinkClick,
|
||||
eventSink = eventSink,
|
||||
modifier = contentModifier,
|
||||
onContentLayoutChange = onContentLayoutChange
|
||||
)
|
||||
},
|
||||
) {
|
||||
Column(modifier = modifier.animateContentSize()) {
|
||||
GroupHeaderView(
|
||||
|
|
@ -142,6 +165,7 @@ private fun TimelineItemGroupedEventsRowContent(
|
|||
eventSink = eventSink,
|
||||
onSwipeToReply = {},
|
||||
onJoinCallClick = {},
|
||||
eventContentView = eventContentView,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ import androidx.compose.ui.unit.dp
|
|||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
||||
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
|
||||
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemCallNotifyContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
|
||||
|
|
@ -59,7 +61,17 @@ internal fun TimelineItemRow(
|
|||
onSwipeToReply: (TimelineItem.Event) -> Unit,
|
||||
onJoinCallClick: () -> Unit,
|
||||
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit =
|
||||
{ event, contentModifier, onContentLayoutChange ->
|
||||
TimelineItemEventContentView(
|
||||
content = event.content,
|
||||
onLinkClick = onLinkClick,
|
||||
eventSink = eventSink,
|
||||
modifier = contentModifier,
|
||||
onContentLayoutChange = onContentLayoutChange
|
||||
)
|
||||
},
|
||||
) {
|
||||
val backgroundModifier = if (timelineItem.isEvent(focusedEventId)) {
|
||||
val focusedEventOffset = if ((timelineItem as? TimelineItem.Event)?.showSenderInformation == true) {
|
||||
|
|
@ -122,6 +134,9 @@ internal fun TimelineItemRow(
|
|||
onReadReceiptClick = onReadReceiptClick,
|
||||
onSwipeToReply = { onSwipeToReply(timelineItem) },
|
||||
eventSink = eventSink,
|
||||
eventContentView = { contentModifier, onContentLayoutChange ->
|
||||
eventContentView(timelineItem, contentModifier, onContentLayoutChange)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,9 +16,6 @@
|
|||
|
||||
package io.element.android.features.messages.impl.timeline.factories
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import io.element.android.features.messages.impl.timeline.TimelineItemIndexer
|
||||
import io.element.android.features.messages.impl.timeline.diff.TimelineItemsCacheInvalidator
|
||||
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemEventFactory
|
||||
|
|
@ -31,9 +28,10 @@ import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
|||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
|
|
@ -46,7 +44,7 @@ class TimelineItemsFactory @Inject constructor(
|
|||
private val timelineItemGrouper: TimelineItemGrouper,
|
||||
private val timelineItemIndexer: TimelineItemIndexer,
|
||||
) {
|
||||
private val timelineItems = MutableStateFlow(persistentListOf<TimelineItem>())
|
||||
private val _timelineItems = MutableSharedFlow<ImmutableList<TimelineItem>>(replay = 1)
|
||||
private val lock = Mutex()
|
||||
private val diffCache = MutableListDiffCache<TimelineItem>()
|
||||
private val diffCacheUpdater = DiffCacheUpdater<MatrixTimelineItem, TimelineItem>(
|
||||
|
|
@ -61,10 +59,7 @@ class TimelineItemsFactory @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun collectItemsAsState(): State<ImmutableList<TimelineItem>> {
|
||||
return timelineItems.collectAsState()
|
||||
}
|
||||
val timelineItems: Flow<ImmutableList<TimelineItem>> = _timelineItems.distinctUntilChanged()
|
||||
|
||||
suspend fun replaceWith(
|
||||
timelineItems: List<MatrixTimelineItem>,
|
||||
|
|
@ -102,7 +97,7 @@ class TimelineItemsFactory @Inject constructor(
|
|||
}
|
||||
val result = timelineItemGrouper.group(newTimelineItemStates).toPersistentList()
|
||||
timelineItemIndexer.process(result)
|
||||
this.timelineItems.emit(result)
|
||||
this._timelineItems.emit(result)
|
||||
}
|
||||
|
||||
private suspend fun buildAndCacheItem(
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@ import app.cash.molecule.moleculeFlow
|
|||
import app.cash.turbine.ReceiveTurbine
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
|
||||
import io.element.android.features.messages.impl.actionlist.ActionListState
|
||||
import io.element.android.features.messages.impl.actionlist.FakeActionListPresenter
|
||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||
import io.element.android.features.messages.impl.draft.FakeComposerDraftService
|
||||
import io.element.android.features.messages.impl.fixtures.aMessageEvent
|
||||
|
|
@ -100,7 +100,6 @@ import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory
|
|||
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
|
||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
|
||||
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
|
||||
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
|
||||
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
|
|
@ -996,7 +995,6 @@ class MessagesPresenterTest {
|
|||
): MessagesPresenter {
|
||||
val mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom)
|
||||
val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter)
|
||||
val appPreferencesStore = InMemoryAppPreferencesStore()
|
||||
val sessionPreferencesStore = InMemorySessionPreferencesStore()
|
||||
val mentionSpanProvider = MentionSpanProvider(FakePermalinkParser())
|
||||
val messageComposerPresenter = MessageComposerPresenter(
|
||||
|
|
@ -1053,11 +1051,6 @@ class MessagesPresenterTest {
|
|||
}
|
||||
}
|
||||
val featureFlagService = FakeFeatureFlagService()
|
||||
val actionListPresenter = ActionListPresenter(
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
featureFlagsService = featureFlagService,
|
||||
room = matrixRoom,
|
||||
)
|
||||
val typingNotificationPresenter = TypingNotificationPresenter(
|
||||
room = matrixRoom,
|
||||
sessionPreferencesStore = sessionPreferencesStore,
|
||||
|
|
@ -1073,7 +1066,7 @@ class MessagesPresenterTest {
|
|||
voiceMessageComposerPresenter = voiceMessageComposerPresenter,
|
||||
timelinePresenterFactory = timelinePresenterFactory,
|
||||
typingNotificationPresenter = typingNotificationPresenter,
|
||||
actionListPresenter = actionListPresenter,
|
||||
actionListPresenterFactory = FakeActionListPresenter.Factory,
|
||||
customReactionPresenter = customReactionPresenter,
|
||||
reactionSummaryPresenter = reactionSummaryPresenter,
|
||||
readReceiptBottomSheetPresenter = readReceiptBottomSheetPresenter,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import app.cash.turbine.test
|
|||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.messages.impl.aUserEventPermissions
|
||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionPostProcessor
|
||||
import io.element.android.features.messages.impl.fixtures.aMessageEvent
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemCallNotifyContent
|
||||
|
|
@ -32,8 +33,6 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI
|
|||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent
|
||||
import io.element.android.features.poll.api.pollcontent.aPollAnswerItemList
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.A_MESSAGE
|
||||
|
|
@ -974,14 +973,10 @@ private fun createActionListPresenter(
|
|||
room: MatrixRoom = FakeMatrixRoom(),
|
||||
): ActionListPresenter {
|
||||
val preferencesStore = InMemoryAppPreferencesStore(isDeveloperModeEnabled = isDeveloperModeEnabled)
|
||||
val featureFlagsService = FakeFeatureFlagService(
|
||||
initialState = mapOf(
|
||||
FeatureFlags.PinnedEvents.key to isPinFeatureEnabled,
|
||||
)
|
||||
)
|
||||
return ActionListPresenter(
|
||||
return DefaultActionListPresenter(
|
||||
postProcessor = TimelineItemActionPostProcessor.Default,
|
||||
appPreferencesStore = preferencesStore,
|
||||
featureFlagsService = featureFlagsService,
|
||||
isPinnedMessagesFeatureEnabled = { isPinFeatureEnabled },
|
||||
room = room
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.actionlist
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionPostProcessor
|
||||
|
||||
class FakeActionListPresenter : ActionListPresenter {
|
||||
object Factory : ActionListPresenter.Factory {
|
||||
override fun create(postProcessor: TimelineItemActionPostProcessor): ActionListPresenter {
|
||||
return FakeActionListPresenter()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun present(): ActionListState {
|
||||
return anActionListState()
|
||||
}
|
||||
}
|
||||
|
|
@ -17,9 +17,12 @@
|
|||
package io.element.android.features.messages.impl.pinned.banner
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.messages.impl.pinned.PinnedEventsTimelineProvider
|
||||
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
||||
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
|
||||
import io.element.android.libraries.eventformatter.test.FakePinnedMessagesBannerFormatter
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
|
|
@ -67,7 +70,7 @@ class PinnedMessagesBannerPresenterTest {
|
|||
}
|
||||
val presenter = createPinnedMessagesBannerPresenter(room = room)
|
||||
presenter.test {
|
||||
skipItems(1)
|
||||
skipItems(2)
|
||||
val loadingState = awaitItem()
|
||||
assertThat(loadingState).isEqualTo(PinnedMessagesBannerState.Loading(1))
|
||||
assertThat(loadingState.pinnedMessagesCount()).isEqualTo(1)
|
||||
|
|
@ -98,7 +101,7 @@ class PinnedMessagesBannerPresenterTest {
|
|||
}
|
||||
val presenter = createPinnedMessagesBannerPresenter(room = room)
|
||||
presenter.test {
|
||||
skipItems(2)
|
||||
skipItems(3)
|
||||
val loadedState = awaitItem() as PinnedMessagesBannerState.Loaded
|
||||
assertThat(loadedState.currentPinnedMessageIndex).isEqualTo(0)
|
||||
assertThat(loadedState.loadedPinnedMessagesCount).isEqualTo(1)
|
||||
|
|
@ -137,7 +140,7 @@ class PinnedMessagesBannerPresenterTest {
|
|||
}
|
||||
val presenter = createPinnedMessagesBannerPresenter(room = room)
|
||||
presenter.test {
|
||||
skipItems(2)
|
||||
skipItems(3)
|
||||
awaitItem().also { loadedState ->
|
||||
loadedState as PinnedMessagesBannerState.Loaded
|
||||
assertThat(loadedState.currentPinnedMessageIndex).isEqualTo(1)
|
||||
|
|
@ -172,7 +175,7 @@ class PinnedMessagesBannerPresenterTest {
|
|||
}
|
||||
val presenter = createPinnedMessagesBannerPresenter(room = room)
|
||||
presenter.test {
|
||||
skipItems(1)
|
||||
skipItems(2)
|
||||
awaitItem().also { loadingState ->
|
||||
assertThat(loadingState).isEqualTo(PinnedMessagesBannerState.Loading(1))
|
||||
assertThat(loadingState.pinnedMessagesCount()).isEqualTo(1)
|
||||
|
|
@ -195,11 +198,19 @@ class PinnedMessagesBannerPresenterTest {
|
|||
networkMonitor: NetworkMonitor = FakeNetworkMonitor(),
|
||||
isFeatureEnabled: Boolean = true,
|
||||
): PinnedMessagesBannerPresenter {
|
||||
val timelineProvider = PinnedEventsTimelineProvider(
|
||||
room = room,
|
||||
networkMonitor = networkMonitor,
|
||||
featureFlagService = FakeFeatureFlagService(
|
||||
initialState = mapOf(FeatureFlags.PinnedEvents.key to isFeatureEnabled)
|
||||
)
|
||||
)
|
||||
timelineProvider.launchIn(backgroundScope)
|
||||
|
||||
return PinnedMessagesBannerPresenter(
|
||||
room = room,
|
||||
itemFactory = itemFactory,
|
||||
isFeatureEnabled = { isFeatureEnabled },
|
||||
networkMonitor = networkMonitor,
|
||||
pinnedEventsTimelineProvider = timelineProvider,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.pinned.list
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||
|
||||
class FakePinnedMessagesListNavigator : PinnedMessagesListNavigator {
|
||||
var onViewInTimelineClickLambda: ((EventId) -> Unit)? = null
|
||||
override fun onViewInTimelineClick(eventId: EventId) {
|
||||
onViewInTimelineClickLambda?.invoke(eventId)
|
||||
}
|
||||
|
||||
var onShowEventDebugInfoClickLambda: ((EventId?, TimelineItemDebugInfo) -> Unit)? = null
|
||||
override fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
|
||||
onShowEventDebugInfoClickLambda?.invoke(eventId, debugInfo)
|
||||
}
|
||||
|
||||
var onForwardEventClickLambda: ((EventId) -> Unit)? = null
|
||||
override fun onForwardEventClick(eventId: EventId) {
|
||||
onForwardEventClickLambda?.invoke(eventId)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,336 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.pinned.list
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.messages.impl.actionlist.FakeActionListPresenter
|
||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactory
|
||||
import io.element.android.features.messages.impl.pinned.PinnedEventsTimelineProvider
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
||||
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.TransactionId
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.A_THROWABLE
|
||||
import io.element.android.libraries.matrix.test.A_UNIQUE_ID
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
||||
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||
import io.element.android.libraries.matrix.test.timeline.aMessageContent
|
||||
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
|
||||
import io.element.android.tests.testutils.lambda.assert
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.test
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class PinnedMessagesListPresenterTest {
|
||||
@Test
|
||||
fun `present - initial state feature disabled`() = runTest {
|
||||
val room = FakeMatrixRoom(
|
||||
canRedactOwnResult = { Result.success(true) },
|
||||
canRedactOtherResult = { Result.success(true) },
|
||||
canUserPinUnpinResult = { Result.success(true) },
|
||||
)
|
||||
val presenter = createPinnedMessagesListPresenter(room = room, isFeatureEnabled = false)
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState).isEqualTo(PinnedMessagesListState.Loading)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - initial state feature enabled`() = runTest {
|
||||
val room = FakeMatrixRoom(
|
||||
canRedactOwnResult = { Result.success(true) },
|
||||
canRedactOtherResult = { Result.success(true) },
|
||||
canUserPinUnpinResult = { Result.success(true) },
|
||||
).apply {
|
||||
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
|
||||
}
|
||||
val presenter = createPinnedMessagesListPresenter(room = room, isFeatureEnabled = true)
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState).isEqualTo(PinnedMessagesListState.Loading)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - timeline failure state`() = runTest {
|
||||
val room = FakeMatrixRoom(
|
||||
pinnedEventsTimelineResult = { Result.failure(RuntimeException()) },
|
||||
canRedactOwnResult = { Result.success(true) },
|
||||
canRedactOtherResult = { Result.success(true) },
|
||||
canUserPinUnpinResult = { Result.success(true) },
|
||||
).apply {
|
||||
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
|
||||
}
|
||||
val presenter = createPinnedMessagesListPresenter(room = room, isFeatureEnabled = true)
|
||||
presenter.test {
|
||||
skipItems(3)
|
||||
val failureState = awaitItem()
|
||||
assertThat(failureState).isEqualTo(PinnedMessagesListState.Failed)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - empty state`() = runTest {
|
||||
val room = FakeMatrixRoom(
|
||||
pinnedEventsTimelineResult = { Result.success(FakeTimeline()) },
|
||||
canRedactOwnResult = { Result.success(true) },
|
||||
canRedactOtherResult = { Result.success(true) },
|
||||
canUserPinUnpinResult = { Result.success(true) },
|
||||
).apply {
|
||||
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf()))
|
||||
}
|
||||
val presenter = createPinnedMessagesListPresenter(room = room, isFeatureEnabled = true)
|
||||
presenter.test {
|
||||
skipItems(3)
|
||||
val emptyState = awaitItem()
|
||||
assertThat(emptyState).isEqualTo(PinnedMessagesListState.Empty)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - filled state`() = runTest {
|
||||
val pinnedEventsTimeline = createPinnedMessagesTimeline()
|
||||
val room = FakeMatrixRoom(
|
||||
pinnedEventsTimelineResult = { Result.success(pinnedEventsTimeline) },
|
||||
canRedactOwnResult = { Result.success(true) },
|
||||
canRedactOtherResult = { Result.success(true) },
|
||||
canUserPinUnpinResult = { Result.success(true) },
|
||||
).apply {
|
||||
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
|
||||
}
|
||||
val presenter = createPinnedMessagesListPresenter(room = room, isFeatureEnabled = true)
|
||||
presenter.test {
|
||||
skipItems(3)
|
||||
val filledState = awaitItem() as PinnedMessagesListState.Filled
|
||||
assertThat(filledState.timelineItems).hasSize(1)
|
||||
assertThat(filledState.loadedPinnedMessagesCount).isEqualTo(1)
|
||||
assertThat(filledState.userEventPermissions.canRedactOwn).isTrue()
|
||||
assertThat(filledState.userEventPermissions.canRedactOther).isTrue()
|
||||
assertThat(filledState.userEventPermissions.canPinUnpin).isTrue()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - redact event`() = runTest {
|
||||
val redactEventLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String? -> Result.success(true) }
|
||||
val pinnedEventsTimeline = createPinnedMessagesTimeline().apply {
|
||||
this.redactEventLambda = redactEventLambda
|
||||
}
|
||||
val room = FakeMatrixRoom(
|
||||
pinnedEventsTimelineResult = { Result.success(pinnedEventsTimeline) },
|
||||
canRedactOwnResult = { Result.success(true) },
|
||||
canRedactOtherResult = { Result.success(true) },
|
||||
canUserPinUnpinResult = { Result.success(true) },
|
||||
).apply {
|
||||
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
|
||||
}
|
||||
val presenter = createPinnedMessagesListPresenter(room = room, isFeatureEnabled = true)
|
||||
presenter.test {
|
||||
skipItems(3)
|
||||
val filledState = awaitItem() as PinnedMessagesListState.Filled
|
||||
val eventItem = filledState.timelineItems.first() as TimelineItem.Event
|
||||
filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.Redact, eventItem))
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
assert(redactEventLambda)
|
||||
.isCalledOnce()
|
||||
.with(value(AN_EVENT_ID), value(null), value(null))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - unpin event`() = runTest {
|
||||
val successUnpinEventLambda = lambdaRecorder { _: EventId? -> Result.success(true) }
|
||||
val failureUnpinEventLambda = lambdaRecorder { _: EventId? -> Result.failure<Boolean>(A_THROWABLE) }
|
||||
val pinnedEventsTimeline = createPinnedMessagesTimeline()
|
||||
|
||||
val room = FakeMatrixRoom(
|
||||
pinnedEventsTimelineResult = { Result.success(pinnedEventsTimeline) },
|
||||
canRedactOwnResult = { Result.success(true) },
|
||||
canRedactOtherResult = { Result.success(true) },
|
||||
canUserPinUnpinResult = { Result.success(true) },
|
||||
).apply {
|
||||
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
|
||||
}
|
||||
val presenter = createPinnedMessagesListPresenter(room = room, isFeatureEnabled = true)
|
||||
presenter.test {
|
||||
skipItems(3)
|
||||
val filledState = awaitItem() as PinnedMessagesListState.Filled
|
||||
val eventItem = filledState.timelineItems.first() as TimelineItem.Event
|
||||
|
||||
pinnedEventsTimeline.unpinEventLambda = successUnpinEventLambda
|
||||
filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.Unpin, eventItem))
|
||||
|
||||
pinnedEventsTimeline.unpinEventLambda = failureUnpinEventLambda
|
||||
filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.Unpin, eventItem))
|
||||
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
|
||||
assert(successUnpinEventLambda)
|
||||
.isCalledOnce()
|
||||
.with(value(AN_EVENT_ID))
|
||||
|
||||
assert(failureUnpinEventLambda)
|
||||
.isCalledOnce()
|
||||
.with(value(AN_EVENT_ID))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - navigate to event`() = runTest {
|
||||
val onViewInTimelineClickLambda = lambdaRecorder { _: EventId -> }
|
||||
val navigator = FakePinnedMessagesListNavigator().apply {
|
||||
this.onViewInTimelineClickLambda = onViewInTimelineClickLambda
|
||||
}
|
||||
val pinnedEventsTimeline = createPinnedMessagesTimeline()
|
||||
val room = FakeMatrixRoom(
|
||||
pinnedEventsTimelineResult = { Result.success(pinnedEventsTimeline) },
|
||||
canRedactOwnResult = { Result.success(true) },
|
||||
canRedactOtherResult = { Result.success(true) },
|
||||
canUserPinUnpinResult = { Result.success(true) },
|
||||
).apply {
|
||||
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
|
||||
}
|
||||
val presenter = createPinnedMessagesListPresenter(room = room, navigator = navigator, isFeatureEnabled = true)
|
||||
presenter.test {
|
||||
skipItems(3)
|
||||
val filledState = awaitItem() as PinnedMessagesListState.Filled
|
||||
val eventItem = filledState.timelineItems.first() as TimelineItem.Event
|
||||
filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.ViewInTimeline, eventItem))
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
assert(onViewInTimelineClickLambda)
|
||||
.isCalledOnce()
|
||||
.with(value(AN_EVENT_ID))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - show view source action`() = runTest {
|
||||
val onShowEventDebugInfoClickLambda = lambdaRecorder { _: EventId?, _: TimelineItemDebugInfo -> }
|
||||
val navigator = FakePinnedMessagesListNavigator().apply {
|
||||
this.onShowEventDebugInfoClickLambda = onShowEventDebugInfoClickLambda
|
||||
}
|
||||
val pinnedEventsTimeline = createPinnedMessagesTimeline()
|
||||
val room = FakeMatrixRoom(
|
||||
pinnedEventsTimelineResult = { Result.success(pinnedEventsTimeline) },
|
||||
canRedactOwnResult = { Result.success(true) },
|
||||
canRedactOtherResult = { Result.success(true) },
|
||||
canUserPinUnpinResult = { Result.success(true) },
|
||||
).apply {
|
||||
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
|
||||
}
|
||||
val presenter = createPinnedMessagesListPresenter(room = room, navigator = navigator, isFeatureEnabled = true)
|
||||
presenter.test {
|
||||
skipItems(3)
|
||||
val filledState = awaitItem() as PinnedMessagesListState.Filled
|
||||
val eventItem = filledState.timelineItems.first() as TimelineItem.Event
|
||||
filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.ViewSource, eventItem))
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
assert(onShowEventDebugInfoClickLambda)
|
||||
.isCalledOnce()
|
||||
.with(value(AN_EVENT_ID), value(eventItem.debugInfo))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - forward event`() = runTest {
|
||||
val onForwardEventClickLambda = lambdaRecorder { _: EventId -> }
|
||||
val navigator = FakePinnedMessagesListNavigator().apply {
|
||||
this.onForwardEventClickLambda = onForwardEventClickLambda
|
||||
}
|
||||
val pinnedEventsTimeline = createPinnedMessagesTimeline()
|
||||
val room = FakeMatrixRoom(
|
||||
pinnedEventsTimelineResult = { Result.success(pinnedEventsTimeline) },
|
||||
canRedactOwnResult = { Result.success(true) },
|
||||
canRedactOtherResult = { Result.success(true) },
|
||||
canUserPinUnpinResult = { Result.success(true) },
|
||||
).apply {
|
||||
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
|
||||
}
|
||||
val presenter = createPinnedMessagesListPresenter(room = room, navigator = navigator, isFeatureEnabled = true)
|
||||
presenter.test {
|
||||
skipItems(3)
|
||||
val filledState = awaitItem() as PinnedMessagesListState.Filled
|
||||
val eventItem = filledState.timelineItems.first() as TimelineItem.Event
|
||||
filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.Forward, eventItem))
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
assert(onForwardEventClickLambda)
|
||||
.isCalledOnce()
|
||||
.with(value(AN_EVENT_ID))
|
||||
}
|
||||
}
|
||||
|
||||
private fun createPinnedMessagesTimeline(): FakeTimeline {
|
||||
val messageContent = aMessageContent("A message")
|
||||
return FakeTimeline(
|
||||
timelineItems = flowOf(
|
||||
listOf(
|
||||
MatrixTimelineItem.Event(
|
||||
uniqueId = A_UNIQUE_ID,
|
||||
event = anEventTimelineItem(
|
||||
eventId = AN_EVENT_ID,
|
||||
content = messageContent,
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun TestScope.createPinnedMessagesListPresenter(
|
||||
navigator: PinnedMessagesListNavigator = FakePinnedMessagesListNavigator(),
|
||||
room: MatrixRoom = FakeMatrixRoom(),
|
||||
networkMonitor: NetworkMonitor = FakeNetworkMonitor(),
|
||||
isFeatureEnabled: Boolean = true,
|
||||
): PinnedMessagesListPresenter {
|
||||
val timelineProvider = PinnedEventsTimelineProvider(
|
||||
room = room,
|
||||
networkMonitor = networkMonitor,
|
||||
featureFlagService = FakeFeatureFlagService(
|
||||
initialState = mapOf(FeatureFlags.PinnedEvents.key to isFeatureEnabled)
|
||||
)
|
||||
)
|
||||
timelineProvider.launchIn(backgroundScope)
|
||||
return PinnedMessagesListPresenter(
|
||||
navigator = navigator,
|
||||
room = room,
|
||||
timelineItemsFactory = aTimelineItemsFactory(),
|
||||
timelineProvider = timelineProvider,
|
||||
snackbarDispatcher = SnackbarDispatcher(),
|
||||
actionListPresenterFactory = FakeActionListPresenter.Factory,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.pinned.list
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.longClick
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onFirst
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTouchInput
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.element.android.features.messages.impl.actionlist.ActionListEvents
|
||||
import io.element.android.features.messages.impl.actionlist.anActionListState
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemList
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemFileContent
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.tests.testutils.EnsureNeverCalled
|
||||
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
|
||||
import io.element.android.tests.testutils.EventsRecorder
|
||||
import io.element.android.tests.testutils.ensureCalledOnce
|
||||
import io.element.android.tests.testutils.ensureCalledOnceWithParam
|
||||
import io.element.android.tests.testutils.pressBack
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TestRule
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PinnedMessagesListViewTest {
|
||||
@get:Rule
|
||||
val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun `clicking on back calls the expected callback`() {
|
||||
val eventsRecorder = EventsRecorder<PinnedMessagesListEvents>(expectEvents = false)
|
||||
val state = aLoadedPinnedMessagesListState(
|
||||
eventSink = eventsRecorder
|
||||
)
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setPinnedMessagesListView(
|
||||
state = state,
|
||||
onBackClick = callback
|
||||
)
|
||||
rule.pressBack()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `click on an event calls the expected callback`() {
|
||||
val eventsRecorder = EventsRecorder<PinnedMessagesListEvents>(expectEvents = false)
|
||||
val content = aTimelineItemFileContent()
|
||||
val state = aLoadedPinnedMessagesListState(
|
||||
timelineItems = aTimelineItemList(content),
|
||||
eventSink = eventsRecorder
|
||||
)
|
||||
|
||||
val event = state.timelineItems.first() as TimelineItem.Event
|
||||
ensureCalledOnceWithParam(event) { callback ->
|
||||
rule.setPinnedMessagesListView(
|
||||
state = state,
|
||||
onEventClick = callback
|
||||
)
|
||||
rule.onAllNodesWithText(content.body).onFirst().performClick()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `long click on an event emits the expected event`() {
|
||||
val eventsRecorder = EventsRecorder<ActionListEvents>(expectEvents = true)
|
||||
val content = aTimelineItemFileContent()
|
||||
val state = aLoadedPinnedMessagesListState(
|
||||
timelineItems = aTimelineItemList(content),
|
||||
actionListState = anActionListState(eventSink = eventsRecorder)
|
||||
)
|
||||
|
||||
rule.setPinnedMessagesListView(
|
||||
state = state,
|
||||
)
|
||||
rule.onAllNodesWithText(content.body).onFirst()
|
||||
.performTouchInput {
|
||||
longClick()
|
||||
}
|
||||
val event = state.timelineItems.first() as TimelineItem.Event
|
||||
eventsRecorder.assertSingle(ActionListEvents.ComputeForMessage(event, state.userEventPermissions))
|
||||
}
|
||||
}
|
||||
|
||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setPinnedMessagesListView(
|
||||
state: PinnedMessagesListState,
|
||||
onBackClick: () -> Unit = EnsureNeverCalled(),
|
||||
onEventClick: (event: TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onUserDataClick: (UserId) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onLinkClick: (String) -> Unit = EnsureNeverCalledWithParam(),
|
||||
) {
|
||||
setContent {
|
||||
PinnedMessagesListView(
|
||||
state = state,
|
||||
onBackClick = onBackClick,
|
||||
onEventClick = onEventClick,
|
||||
onUserDataClick = onUserDataClick,
|
||||
onLinkClick = onLinkClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -19,10 +19,8 @@ package io.element.android.features.poll.api.pollcontent
|
|||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
import androidx.compose.runtime.Composable
|
||||
|
|
@ -36,12 +34,10 @@ import androidx.compose.ui.res.stringResource
|
|||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Button
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.poll.PollAnswer
|
||||
|
|
@ -117,7 +113,7 @@ fun PollContentView(
|
|||
modifier = modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
PollTitle(title = question, isPollEnded = isPollEnded)
|
||||
PollTitleView(title = question, isPollEnded = isPollEnded)
|
||||
|
||||
PollAnswers(answerItems = answerItems, onSelectAnswer = ::onSelectAnswer)
|
||||
|
||||
|
|
@ -139,34 +135,6 @@ fun PollContentView(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PollTitle(
|
||||
title: String,
|
||||
isPollEnded: Boolean,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
if (isPollEnded) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.PollsEnd(),
|
||||
contentDescription = stringResource(id = CommonStrings.a11y_poll_end),
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Polls(),
|
||||
contentDescription = stringResource(id = CommonStrings.a11y_poll),
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = title,
|
||||
style = ElementTheme.typography.fontBodyLgMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PollAnswers(
|
||||
answerItems: ImmutableList<PollAnswerItem>,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.poll.api.pollcontent
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
fun PollTitleView(
|
||||
title: String,
|
||||
isPollEnded: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
if (isPollEnded) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.PollsEnd(),
|
||||
contentDescription = stringResource(id = CommonStrings.a11y_poll_end),
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Polls(),
|
||||
contentDescription = stringResource(id = CommonStrings.a11y_poll),
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = title,
|
||||
style = ElementTheme.typography.fontBodyLgMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun PollTitleViewPreview() = ElementPreview {
|
||||
PollTitleView(
|
||||
title = "What is your favorite color?",
|
||||
isPollEnded = false
|
||||
)
|
||||
}
|
||||
|
|
@ -32,8 +32,8 @@ import io.element.android.features.poll.impl.history.model.PollHistoryFilter
|
|||
import io.element.android.features.poll.impl.history.model.PollHistoryItems
|
||||
import io.element.android.features.poll.impl.history.model.PollHistoryItemsFactory
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -44,11 +44,11 @@ class PollHistoryPresenter @Inject constructor(
|
|||
private val sendPollResponseAction: SendPollResponseAction,
|
||||
private val endPollAction: EndPollAction,
|
||||
private val pollHistoryItemFactory: PollHistoryItemsFactory,
|
||||
private val timelineProvider: TimelineProvider,
|
||||
private val room: MatrixRoom,
|
||||
) : Presenter<PollHistoryState> {
|
||||
@Composable
|
||||
override fun present(): PollHistoryState {
|
||||
val timeline by timelineProvider.activeTimelineFlow().collectAsState()
|
||||
val timeline = room.liveTimeline
|
||||
val paginationState by timeline.paginationStatus(Timeline.PaginationDirection.BACKWARDS).collectAsState()
|
||||
val pollHistoryItemsFlow = remember {
|
||||
timeline.timelineItems.map { items ->
|
||||
|
|
|
|||
|
|
@ -38,7 +38,6 @@ import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
|
|||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||
import io.element.android.libraries.matrix.test.timeline.LiveTimelineProvider
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.lambda.assert
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
|
|
@ -180,7 +179,7 @@ class PollHistoryPresenterTest {
|
|||
sendPollResponseAction = sendPollResponseAction,
|
||||
endPollAction = endPollAction,
|
||||
pollHistoryItemFactory = pollHistoryItemFactory,
|
||||
timelineProvider = LiveTimelineProvider(room),
|
||||
room = room,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import io.element.android.libraries.architecture.FeatureEntryPoint
|
|||
import io.element.android.libraries.architecture.NodeInputs
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
interface RoomDetailsEntryPoint : FeatureEntryPoint {
|
||||
|
|
@ -43,6 +44,8 @@ interface RoomDetailsEntryPoint : FeatureEntryPoint {
|
|||
interface Callback : Plugin {
|
||||
fun onOpenGlobalNotificationSettings()
|
||||
fun onOpenRoom(roomId: RoomId)
|
||||
fun onPermalinkClick(data: PermalinkData, pushToBackstack: Boolean)
|
||||
fun onForwardedToSingleRoom(roomId: RoomId)
|
||||
}
|
||||
|
||||
interface NodeBuilder {
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ dependencies {
|
|||
implementation(projects.features.userprofile.shared)
|
||||
implementation(projects.services.analytics.api)
|
||||
implementation(projects.features.poll.api)
|
||||
implementation(projects.features.messages.api)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.coroutines.test)
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import im.vector.app.features.analytics.plan.Interaction
|
|||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.features.call.api.CallType
|
||||
import io.element.android.features.call.api.ElementCallEntryPoint
|
||||
import io.element.android.features.messages.api.MessagesEntryPoint
|
||||
import io.element.android.features.poll.api.history.PollHistoryEntryPoint
|
||||
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
|
||||
import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditNode
|
||||
|
|
@ -49,6 +50,7 @@ import io.element.android.libraries.di.RoomScope
|
|||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.mediaviewer.api.local.MediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode
|
||||
|
|
@ -64,6 +66,7 @@ class RoomDetailsFlowNode @AssistedInject constructor(
|
|||
private val elementCallEntryPoint: ElementCallEntryPoint,
|
||||
private val room: MatrixRoom,
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val messagesEntryPoint: MessagesEntryPoint,
|
||||
) : BaseFlowNode<RoomDetailsFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = plugins.filterIsInstance<RoomDetailsEntryPoint.Params>().first().initialElement.toNavTarget(),
|
||||
|
|
@ -105,6 +108,9 @@ class RoomDetailsFlowNode @AssistedInject constructor(
|
|||
|
||||
@Parcelize
|
||||
data object AdminSettings : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object PinnedMessagesList : NavTarget
|
||||
}
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
|
|
@ -139,6 +145,10 @@ class RoomDetailsFlowNode @AssistedInject constructor(
|
|||
backstack.push(NavTarget.AdminSettings)
|
||||
}
|
||||
|
||||
override fun openPinnedMessagesList() {
|
||||
backstack.push(NavTarget.PinnedMessagesList)
|
||||
}
|
||||
|
||||
override fun onJoinCall() {
|
||||
val inputs = CallType.RoomCall(
|
||||
sessionId = room.sessionId,
|
||||
|
|
@ -224,6 +234,28 @@ class RoomDetailsFlowNode @AssistedInject constructor(
|
|||
is NavTarget.AdminSettings -> {
|
||||
createNode<RolesAndPermissionsFlowNode>(buildContext)
|
||||
}
|
||||
NavTarget.PinnedMessagesList -> {
|
||||
val params = MessagesEntryPoint.Params(
|
||||
MessagesEntryPoint.InitialTarget.PinnedMessages
|
||||
)
|
||||
val callback = object : MessagesEntryPoint.Callback {
|
||||
override fun onRoomDetailsClick() = Unit
|
||||
|
||||
override fun onUserDataClick(userId: UserId) = Unit
|
||||
|
||||
override fun onPermalinkClick(data: PermalinkData, pushToBackstack: Boolean) {
|
||||
plugins<RoomDetailsEntryPoint.Callback>().forEach { it.onPermalinkClick(data, pushToBackstack) }
|
||||
}
|
||||
|
||||
override fun onForwardedToSingleRoom(roomId: RoomId) {
|
||||
plugins<RoomDetailsEntryPoint.Callback>().forEach { it.onForwardedToSingleRoom(roomId) }
|
||||
}
|
||||
}
|
||||
return messagesEntryPoint.nodeBuilder(this, buildContext)
|
||||
.params(params)
|
||||
.callback(callback)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ class RoomDetailsNode @AssistedInject constructor(
|
|||
fun openAvatarPreview(name: String, url: String)
|
||||
fun openPollHistory()
|
||||
fun openAdminSettings()
|
||||
fun openPinnedMessagesList()
|
||||
fun onJoinCall()
|
||||
}
|
||||
|
||||
|
|
@ -115,6 +116,10 @@ class RoomDetailsNode @AssistedInject constructor(
|
|||
callbacks.forEach { it.openAdminSettings() }
|
||||
}
|
||||
|
||||
private fun openPinnedMessages() {
|
||||
callbacks.forEach { it.openPinnedMessagesList() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val context = LocalContext.current
|
||||
|
|
@ -144,6 +149,7 @@ class RoomDetailsNode @AssistedInject constructor(
|
|||
openPollHistory = ::openPollHistory,
|
||||
openAdminSettings = this::openAdminSettings,
|
||||
onJoinCallClick = ::onJoinCall,
|
||||
onPinnedMessagesClick = ::openPinnedMessages
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||
import im.vector.app.features.analytics.plan.Interaction
|
||||
import io.element.android.features.leaveroom.api.LeaveRoomEvent
|
||||
import io.element.android.features.leaveroom.api.LeaveRoomPresenter
|
||||
import io.element.android.features.messages.api.pinned.IsPinnedMessagesFeatureEnabled
|
||||
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.bool.orFalse
|
||||
|
|
@ -67,6 +68,7 @@ class RoomDetailsPresenter @Inject constructor(
|
|||
private val leaveRoomPresenter: LeaveRoomPresenter,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val isPinnedMessagesFeatureEnabled: IsPinnedMessagesFeatureEnabled,
|
||||
) : Presenter<RoomDetailsState> {
|
||||
@Composable
|
||||
override fun present(): RoomDetailsState {
|
||||
|
|
@ -83,6 +85,9 @@ class RoomDetailsPresenter @Inject constructor(
|
|||
val isFavorite by remember { derivedStateOf { roomInfo?.isFavorite.orFalse() } }
|
||||
val isPublic by remember { derivedStateOf { roomInfo?.isPublic.orFalse() } }
|
||||
|
||||
val canShowPinnedMessages = isPinnedMessagesFeatureEnabled()
|
||||
val pinnedMessagesCount by remember { derivedStateOf { roomInfo?.pinnedEventIds?.size } }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
canShowNotificationSettings.value = featureFlagService.isFeatureEnabled(FeatureFlags.NotificationSettings)
|
||||
if (canShowNotificationSettings.value) {
|
||||
|
|
@ -156,6 +161,8 @@ class RoomDetailsPresenter @Inject constructor(
|
|||
displayRolesAndPermissionsSettings = !room.isDm && isUserAdmin,
|
||||
isPublic = isPublic,
|
||||
heroes = roomInfo?.heroes.orEmpty().toPersistentList(),
|
||||
canShowPinnedMessages = canShowPinnedMessages,
|
||||
pinnedMessagesCount = pinnedMessagesCount,
|
||||
eventSink = ::handleEvents,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@ data class RoomDetailsState(
|
|||
val displayRolesAndPermissionsSettings: Boolean,
|
||||
val isPublic: Boolean,
|
||||
val heroes: ImmutableList<MatrixUser>,
|
||||
val canShowPinnedMessages: Boolean,
|
||||
val pinnedMessagesCount: Int?,
|
||||
val eventSink: (RoomDetailsEvent) -> Unit
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ open class RoomDetailsStateProvider : PreviewParameterProvider<RoomDetailsState>
|
|||
aRoomDetailsState(canCall = false, canInvite = false),
|
||||
aRoomDetailsState(isPublic = false),
|
||||
aRoomDetailsState(heroes = aMatrixUserList()),
|
||||
aRoomDetailsState(pinnedMessagesCount = 3),
|
||||
// Add other state here
|
||||
)
|
||||
}
|
||||
|
|
@ -105,6 +106,8 @@ fun aRoomDetailsState(
|
|||
displayAdminSettings: Boolean = false,
|
||||
isPublic: Boolean = true,
|
||||
heroes: List<MatrixUser> = emptyList(),
|
||||
canShowPinnedMessages: Boolean = true,
|
||||
pinnedMessagesCount: Int? = null,
|
||||
eventSink: (RoomDetailsEvent) -> Unit = {},
|
||||
) = RoomDetailsState(
|
||||
roomId = roomId,
|
||||
|
|
@ -126,6 +129,8 @@ fun aRoomDetailsState(
|
|||
displayRolesAndPermissionsSettings = displayAdminSettings,
|
||||
isPublic = isPublic,
|
||||
heroes = heroes.toPersistentList(),
|
||||
canShowPinnedMessages = canShowPinnedMessages,
|
||||
pinnedMessagesCount = pinnedMessagesCount,
|
||||
eventSink = eventSink
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.consumeWindowInsets
|
|||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
|
|
@ -66,6 +67,7 @@ import io.element.android.libraries.designsystem.components.preferences.Preferen
|
|||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight
|
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||
import io.element.android.libraries.designsystem.theme.components.DropdownMenu
|
||||
import io.element.android.libraries.designsystem.theme.components.DropdownMenuItem
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
|
|
@ -103,6 +105,7 @@ fun RoomDetailsView(
|
|||
openPollHistory: () -> Unit,
|
||||
openAdminSettings: () -> Unit,
|
||||
onJoinCallClick: () -> Unit,
|
||||
onPinnedMessagesClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Scaffold(
|
||||
|
|
@ -183,6 +186,13 @@ fun RoomDetailsView(
|
|||
}
|
||||
)
|
||||
|
||||
if (state.canShowPinnedMessages) {
|
||||
PinnedMessagesItem(
|
||||
pinnedMessagesCount = state.pinnedMessagesCount,
|
||||
onPinnedMessagesClick = onPinnedMessagesClick
|
||||
)
|
||||
}
|
||||
|
||||
if (state.displayRolesAndPermissionsSettings) {
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.screen_room_details_roles_and_permissions)) },
|
||||
|
|
@ -503,6 +513,26 @@ private fun MembersItem(
|
|||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PinnedMessagesItem(
|
||||
pinnedMessagesCount: Int?,
|
||||
onPinnedMessagesClick: () -> Unit,
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(CommonStrings.screen_room_details_pinned_events_row_title)) },
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Pin())),
|
||||
trailingContent =
|
||||
if (pinnedMessagesCount == null) {
|
||||
ListItemContent.Custom {
|
||||
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(24.dp))
|
||||
}
|
||||
} else {
|
||||
ListItemContent.Text(pinnedMessagesCount.toString())
|
||||
},
|
||||
onClick = onPinnedMessagesClick,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PollsSection(
|
||||
openPollHistory: () -> Unit,
|
||||
|
|
@ -573,5 +603,6 @@ private fun ContentToPreview(state: RoomDetailsState) {
|
|||
openPollHistory = {},
|
||||
openAdminSettings = {},
|
||||
onJoinCallClick = {},
|
||||
onPinnedMessagesClick = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
|
|||
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
||||
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
|
||||
import io.element.android.libraries.matrix.api.room.StateEventType
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
|
|
@ -81,6 +82,7 @@ class RoomDetailsPresenterTest {
|
|||
dispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
|
||||
notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(),
|
||||
analyticsService: AnalyticsService = FakeAnalyticsService(),
|
||||
isPinnedMessagesFeatureEnabled: Boolean = true,
|
||||
): RoomDetailsPresenter {
|
||||
val matrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService)
|
||||
val roomMemberDetailsPresenterFactory = object : RoomMemberDetailsPresenter.Factory {
|
||||
|
|
@ -99,6 +101,7 @@ class RoomDetailsPresenterTest {
|
|||
roomMembersDetailsPresenterFactory = roomMemberDetailsPresenterFactory,
|
||||
leaveRoomPresenter = leaveRoomPresenter,
|
||||
dispatchers = dispatchers,
|
||||
isPinnedMessagesFeatureEnabled = { isPinnedMessagesFeatureEnabled },
|
||||
analyticsService = analyticsService,
|
||||
)
|
||||
}
|
||||
|
|
@ -127,14 +130,15 @@ class RoomDetailsPresenterTest {
|
|||
assertThat(initialState.roomTopic).isEqualTo(RoomTopicState.ExistingTopic(room.topic!!))
|
||||
assertThat(initialState.memberCount).isEqualTo(room.joinedMemberCount)
|
||||
assertThat(initialState.isEncrypted).isEqualTo(room.isEncrypted)
|
||||
|
||||
assertThat(initialState.canShowPinnedMessages).isTrue()
|
||||
assertThat(initialState.pinnedMessagesCount).isNull()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - initial state is updated with roomInfo if it exists`() = runTest {
|
||||
val roomInfo = aRoomInfo(name = "A room name", topic = "A topic", avatarUrl = "https://matrix.org/avatar.jpg")
|
||||
val roomInfo = aRoomInfo(name = "A room name", topic = "A topic", avatarUrl = "https://matrix.org/avatar.jpg", pinnedEventIds = listOf(AN_EVENT_ID))
|
||||
val room = aMatrixRoom(
|
||||
canInviteResult = { Result.success(true) },
|
||||
canUserJoinCallResult = { Result.success(true) },
|
||||
|
|
@ -149,7 +153,7 @@ class RoomDetailsPresenterTest {
|
|||
assertThat(updatedState.roomName).isEqualTo(roomInfo.name)
|
||||
assertThat(updatedState.roomAvatarUrl).isEqualTo(roomInfo.avatarUrl)
|
||||
assertThat(updatedState.roomTopic).isEqualTo(RoomTopicState.ExistingTopic(roomInfo.topic!!))
|
||||
|
||||
assertThat(updatedState.pinnedMessagesCount).isEqualTo(roomInfo.pinnedEventIds.size)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -127,6 +127,21 @@ class RoomDetailsViewTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Config(qualifiers = "h1024dp")
|
||||
@Test
|
||||
fun `click on pinned messages invokes expected callback`() {
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setRoomDetailView(
|
||||
state = aRoomDetailsState(
|
||||
eventSink = EventsRecorder(expectEvents = false),
|
||||
canInvite = true,
|
||||
),
|
||||
onPinnedMessagesClick = callback,
|
||||
)
|
||||
rule.clickOn(CommonStrings.screen_room_details_pinned_events_row_title)
|
||||
}
|
||||
}
|
||||
|
||||
@Config(qualifiers = "h1024dp")
|
||||
@Test
|
||||
fun `click on add topic emit expected event`() {
|
||||
|
|
@ -263,6 +278,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomD
|
|||
openPollHistory: () -> Unit = EnsureNeverCalled(),
|
||||
openAdminSettings: () -> Unit = EnsureNeverCalled(),
|
||||
onJoinCallClick: () -> Unit = EnsureNeverCalled(),
|
||||
onPinnedMessagesClick: () -> Unit = EnsureNeverCalled(),
|
||||
) {
|
||||
setContent {
|
||||
RoomDetailsView(
|
||||
|
|
@ -277,6 +293,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomD
|
|||
openPollHistory = openPollHistory,
|
||||
openAdminSettings = openAdminSettings,
|
||||
onJoinCallClick = onJoinCallClick,
|
||||
onPinnedMessagesClick = onPinnedMessagesClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue