Pinned events : start displaying actions in timeline

This commit is contained in:
ganfra
2024-08-28 21:59:10 +02:00
parent 3bcd76d271
commit 3cc749dd0a
24 changed files with 474 additions and 86 deletions

View File

@@ -21,12 +21,14 @@ 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
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.newRoot
import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
@@ -44,6 +46,7 @@ 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
@@ -69,6 +72,7 @@ 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.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
@@ -98,9 +102,10 @@ class MessagesFlowNode @AssistedInject constructor(
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 = NavTarget.Messages(overriddenFocusedEventId = null),
savedStateMap = buildContext.savedStateMap,
),
overlay = Overlay(
@@ -118,7 +123,7 @@ class MessagesFlowNode @AssistedInject constructor(
data object Empty : NavTarget
@Parcelize
data object Messages : NavTarget
data class Messages(val overriddenFocusedEventId: EventId?) : NavTarget
@Parcelize
data class MediaViewer(
@@ -137,7 +142,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
@@ -159,7 +164,11 @@ class MessagesFlowNode @AssistedInject constructor(
override fun onBuilt() {
super.onBuilt()
lifecycle.subscribe(
onDestroy = {
timelineController.close()
}
)
room.membersStateFlow
.onEach { membersState ->
roomMemberProfilesCache.replace(membersState.joinedRoomMembers())
@@ -167,7 +176,6 @@ class MessagesFlowNode @AssistedInject constructor(
.launchIn(lifecycleScope)
pinnedEventsTimelineProvider.launchIn(lifecycleScope)
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@@ -199,7 +207,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) {
@@ -232,7 +240,7 @@ class MessagesFlowNode @AssistedInject constructor(
}
}
val inputs = MessagesNode.Inputs(
focusedEventId = inputs.focusedEventId,
focusedEventId = navTarget.overriddenFocusedEventId ?: inputs.focusedEventId,
)
createNode<MessagesNode>(buildContext, listOf(callback, inputs))
}
@@ -259,7 +267,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) }
@@ -294,8 +307,25 @@ class MessagesFlowNode @AssistedInject constructor(
callbacks.forEach { it.onUserDataClick(userId) }
}
override fun onPermalinkClick(data: PermalinkData) {
callbacks.forEach { it.onPermalinkClick(data) }
override fun onViewInTimelineClick(eventId: EventId) {
backstack.newRoot(NavTarget.Messages(overriddenFocusedEventId = eventId))
}
override fun onRoomPermalinkClick(data: PermalinkData.RoomLink) {
if (room.matches(data.roomIdOrAlias)) {
val eventId = data.eventId
backstack.newRoot(NavTarget.Messages(overriddenFocusedEventId = eventId))
} else {
callbacks.forEach { it.onPermalinkClick(data) }
}
}
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))

View File

@@ -37,7 +37,6 @@ import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
@@ -75,7 +74,6 @@ class MessagesNode @AssistedInject constructor(
private val permalinkParser: PermalinkParser,
@ApplicationContext
private val context: Context,
private val timelineController: TimelineController,
) : Node(buildContext, plugins = plugins), MessagesNavigator {
private val presenter = presenterFactory.create(this)
private val callbacks = plugins<Callback>()
@@ -107,7 +105,6 @@ class MessagesNode @AssistedInject constructor(
analyticsService.capture(room.toAnalyticsViewRoom())
},
onDestroy = {
timelineController.close()
mediaPlayer.close()
}
)
@@ -202,6 +199,8 @@ class MessagesNode @AssistedInject constructor(
callbacks.forEach { it.onJoinCallClick(room.roomId) }
}
@Composable
override fun View(modifier: Modifier) {
val context = LocalContext.current

View File

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

View File

@@ -23,8 +23,12 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.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
@@ -47,13 +51,19 @@ 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(
class ActionListPresenter @AssistedInject constructor(
@Assisted
private val postProcessor: TimelineItemActionPostProcessor,
private val appPreferencesStore: AppPreferencesStore,
private val featureFlagsService: FeatureFlagService,
private val room: MatrixRoom,
) : Presenter<ActionListState> {
@AssistedFactory
interface Factory {
fun create(postProcessor: TimelineItemActionPostProcessor): ActionListPresenter
}
@Composable
override fun present(): ActionListState {
val localCoroutineScope = rememberCoroutineScope()
@@ -105,6 +115,7 @@ class ActionListPresenter @Inject constructor(
isPinnedEventsEnabled = isPinnedEventsEnabled,
isEventPinned = pinnedEventIds.contains(timelineItem.eventId),
)
val displayEmojiReactions = usersEventPermissions.canSendReaction &&
timelineItem.isRemote &&
timelineItem.content.canReact()
@@ -118,57 +129,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)
}
}
/**

View File

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

View File

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

View File

@@ -34,8 +34,11 @@ 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.Timeline
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.coroutines.flow.StateFlow
import kotlinx.parcelize.Parcelize
@ContributesNode(RoomScope::class)
@@ -59,10 +62,13 @@ class ForwardMessagesNode @AssistedInject constructor(
fun onForwardedToSingleRoom(roomId: RoomId)
}
data class Inputs(val eventId: EventId) : NodeInputs
data class Inputs(
val eventId: EventId,
val timelineProvider: TimelineProvider,
) : NodeInputs
private val inputs = inputs<Inputs>()
private val presenter = presenterFactory.create(inputs.eventId.value)
private val presenter = presenterFactory.create(inputs.eventId.value, inputs.timelineProvider)
private val callbacks = plugins.filterIsInstance<Callback>()
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {

View File

@@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.forward
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
@@ -27,23 +28,27 @@ import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
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.Timeline
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
import io.element.android.libraries.matrix.api.timeline.getActiveTimeline
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import java.sql.Time
class ForwardMessagesPresenter @AssistedInject constructor(
@Assisted eventId: String,
@Assisted private val timelineProvider: TimelineProvider,
private val appCoroutineScope: CoroutineScope,
private val timelineProvider: TimelineProvider,
) : Presenter<ForwardMessagesState> {
private val eventId: EventId = EventId(eventId)
@AssistedFactory
interface Factory {
fun create(eventId: String): ForwardMessagesPresenter
fun create(eventId: String, timelineProvider: TimelineProvider): ForwardMessagesPresenter
}
private val forwardingActionState: MutableState<AsyncAction<List<RoomId>>> = mutableStateOf(AsyncAction.Uninitialized)

View File

@@ -18,15 +18,17 @@ 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.delay
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
@@ -38,9 +40,16 @@ 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) {
@@ -56,12 +65,12 @@ class PinnedEventsTimelineProvider @Inject constructor(
}
}
.onCompletion {
invokeOnTimeline { it.close() }
invokeOnTimeline { close() }
}
.launchIn(scope)
}
suspend fun invokeOnTimeline(action: suspend (Timeline) -> Unit) {
suspend fun invokeOnTimeline(action: suspend Timeline.() -> Unit) {
when (val asyncTimeline = timelineStateFlow.value) {
is AsyncData.Success -> action(asyncTimeline.data)
else -> Unit

View File

@@ -16,4 +16,10 @@
package io.element.android.features.messages.impl.pinned.list
sealed interface PinnedMessagesListEvents
import io.element.android.features.messages.impl.MessagesEvents
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.timeline.model.TimelineItem
sealed interface PinnedMessagesListEvents {
data class HandleAction(val action: TimelineItemAction, val event: TimelineItem.Event) : PinnedMessagesListEvents
}

View File

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

View File

@@ -28,31 +28,37 @@ 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.MessagesNode.Callback
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactory
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>,
private val presenter: PinnedMessagesListPresenter,
presenterFactory: PinnedMessagesListPresenter.Factory,
private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
private val permalinkParser: PermalinkParser,
) : Node(buildContext, plugins = plugins) {
) : Node(buildContext, plugins = plugins), PinnedMessagesListNavigator {
interface Callback : Plugin {
fun onEventClick(event: TimelineItem.Event)
fun onUserDataClick(userId: UserId)
fun onPermalinkClick(data: PermalinkData)
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) {
@@ -71,7 +77,7 @@ class PinnedMessagesListNode @AssistedInject constructor(
callbacks.forEach { it.onUserDataClick(permalink.userId) }
}
is PermalinkData.RoomLink -> {
callbacks.forEach { it.onPermalinkClick(permalink) }
callbacks.forEach { it.onRoomPermalinkClick(permalink) }
}
is PermalinkData.FallbackLink,
is PermalinkData.RoomEmailInviteLink -> {
@@ -80,6 +86,18 @@ class PinnedMessagesListNode @AssistedInject constructor(
}
}
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(

View File

@@ -18,41 +18,69 @@ 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.flow.combine
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
import kotlinx.coroutines.launch
import timber.log.Timber
class PinnedMessagesListPresenter @Inject constructor(
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,
isMainTimeline = false,
// We don't need to compute those values
userHasPermissionToSendMessage = false,
userHasPermissionToSendReaction = false,
@@ -60,6 +88,9 @@ class PinnedMessagesListPresenter @Inject constructor(
)
}
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val userEventPermissions by userEventPermissions(syncUpdateFlow.value)
var pinnedMessageItems by remember {
mutableStateOf<AsyncData<ImmutableList<TimelineItem>>>(AsyncData.Uninitialized)
}
@@ -70,16 +101,76 @@ class PinnedMessagesListPresenter @Inject constructor(
}
)
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 },
)
}
}
@Composable
private fun PinnedMessagesListEffect(onItemsChange: (AsyncData<ImmutableList<TimelineItem>>) -> Unit) {
val updatedOnItemsChange by rememberUpdatedState(onItemsChange)
@@ -111,8 +202,10 @@ class PinnedMessagesListPresenter @Inject constructor(
}
}
@Composable
private fun pinnedMessagesListState(
timelineRoomInfo: TimelineRoomInfo,
userEventPermissions: UserEventPermissions,
timelineItems: AsyncData<ImmutableList<TimelineItem>>,
eventSink: (PinnedMessagesListEvents) -> Unit
): PinnedMessagesListState {
@@ -123,9 +216,12 @@ class PinnedMessagesListPresenter @Inject constructor(
if (timelineItems.data.isEmpty()) {
PinnedMessagesListState.Empty
} else {
val actionListState = actionListPresenter.present()
PinnedMessagesListState.Filled(
timelineRoomInfo = timelineRoomInfo,
userEventPermissions = userEventPermissions,
timelineItems = timelineItems.data,
actionListState = actionListState,
eventSink = eventSink
)
}

View File

@@ -20,6 +20,8 @@ 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
@@ -33,7 +35,9 @@ sealed interface 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 }

View File

@@ -0,0 +1,41 @@
/*
* 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
object PinnedMessagesListTimelineActionPostProcessor : TimelineItemActionPostProcessor {
override fun process(actions: List<TimelineItemAction>): List<TimelineItemAction> {
return buildList {
add(TimelineItemAction.ViewInTimeline)
addAll(actions.filter(::predicate))
}
}
private fun predicate(action: TimelineItemAction): Boolean {
return when (action) {
is TimelineItemAction.Pin,
is TimelineItemAction.Unpin,
is TimelineItemAction.Redact,
is TimelineItemAction.Forward,
is TimelineItemAction.ViewSource -> true
else -> false
}
}
}

View File

@@ -32,6 +32,9 @@ 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.model.TimelineItem
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
@@ -148,6 +151,33 @@ private fun PinnedMessagesListLoaded(
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(),
@@ -166,7 +196,7 @@ private fun PinnedMessagesListLoaded(
isLastOutgoingMessage = false,
focusedEventId = null,
onClick = onEventClick,
onLongClick = {},
onLongClick = ::onMessageLongClick,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
inReplyToClick = {},

View File

@@ -17,6 +17,9 @@
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.aTimelineRoomInfo
import io.element.android.features.messages.impl.timeline.model.TimelineItem
@@ -41,8 +44,12 @@ fun anEmptyPinnedMessagesListState() = PinnedMessagesListState.Empty
fun aLoadedPinnedMessagesListState(
timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(),
timelineItems: List<TimelineItem> = emptyList(),
actionListState: ActionListState = anActionListState(),
aUserEventPermissions: UserEventPermissions = UserEventPermissions.DEFAULT,
) = PinnedMessagesListState.Filled(
timelineRoomInfo = timelineRoomInfo,
timelineItems = timelineItems.toImmutableList(),
actionListState = actionListState,
userEventPermissions = aUserEventPermissions,
eventSink = {},
)

View File

@@ -45,6 +45,7 @@ import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.timeline.getActiveTimeline
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
import io.element.android.libraries.matrix.ui.room.canSendMessageAsState
@@ -221,6 +222,7 @@ class TimelinePresenter @AssistedInject constructor(
userHasPermissionToSendMessage = userHasPermissionToSendMessage,
userHasPermissionToSendReaction = userHasPermissionToSendReaction,
isCallOngoing = roomInfo?.hasRoomCall.orFalse(),
isMainTimeline = true
)
}
}

View File

@@ -20,6 +20,7 @@ import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.timeline.model.NewEventState
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import kotlinx.collections.immutable.ImmutableList
import kotlin.time.Duration
@@ -74,4 +75,5 @@ data class TimelineRoomInfo(
val userHasPermissionToSendMessage: Boolean,
val userHasPermissionToSendReaction: Boolean,
val isCallOngoing: Boolean,
val isMainTimeline: Boolean,
)

View File

@@ -33,6 +33,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
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.core.UserId
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
@@ -241,10 +242,12 @@ internal fun aTimelineRoomInfo(
name: String = "Room name",
isDm: Boolean = false,
userHasPermissionToSendMessage: Boolean = true,
isMainTimeline: Boolean = true,
) = TimelineRoomInfo(
isDm = isDm,
name = name,
userHasPermissionToSendMessage = userHasPermissionToSendMessage,
userHasPermissionToSendReaction = true,
isCallOngoing = false,
isMainTimeline = isMainTimeline,
)

View File

@@ -32,6 +32,7 @@ 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
@@ -44,11 +45,11 @@ class PollHistoryPresenter @Inject constructor(
private val sendPollResponseAction: SendPollResponseAction,
private val endPollAction: EndPollAction,
private val pollHistoryItemFactory: PollHistoryItemsFactory,
private val timelineProvider: TimelineProvider,
private val room: MatrixRoom,
) : Presenter<PollHistoryState> {
@Composable
override fun present(): PollHistoryState {
val timeline by timelineProvider.activeTimelineFlow().collectAsState()
val timeline = room.liveTimeline
val paginationState by timeline.paginationStatus(Timeline.PaginationDirection.BACKWARDS).collectAsState()
val pollHistoryItemsFlow = remember {
timeline.timelineItems.map { items ->

View File

@@ -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.libraries.core.coroutine
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
/**
* A [StateFlow] that derives its value from a [Flow].
* Useful when you want to apply transformations to a [Flow] and expose it as a [StateFlow].
*/
class DerivedStateFlow<T>(
private val getValue: () -> T,
private val flow: Flow<T>
) : StateFlow<T> {
override val replayCache: List<T>
get() = listOf(value)
override val value: T
get() = getValue()
override suspend fun collect(collector: FlowCollector<T>): Nothing {
coroutineScope { flow.distinctUntilChanged().stateIn(this).collect(collector) }
}
}
/**
* Maps the value of a [StateFlow] to a new value and returns a new [StateFlow] with the mapped value.
*/
fun <T1, R> StateFlow<T1>.mapState(transform: (a: T1) -> R): StateFlow<R> {
return DerivedStateFlow(
getValue = { transform(this.value) },
flow = this.map { a -> transform(a) }
)
}

View File

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

View File

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