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

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

View file

@ -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
}

View file

@ -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) {}
}

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()
}
)

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,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)
}
}
/**

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

View file

@ -34,6 +34,7 @@ import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.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 {

View file

@ -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)

View file

@ -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,

View file

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

View file

@ -26,18 +26,19 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.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)
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -51,6 +51,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemE
import io.element.android.libraries.matrix.ui.room.canSendMessageAsState
import io.element.android.libraries.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()

View file

@ -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 ->

View file

@ -28,7 +28,9 @@ import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.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,
)
}
}

View file

@ -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)
},
)
}
}

View file

@ -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(