Pinned events : start branching sdk data in the banner.

This commit is contained in:
ganfra 2024-08-01 18:34:52 +02:00
parent ebc9fd745e
commit 9985526137
10 changed files with 201 additions and 56 deletions

View file

@ -102,5 +102,6 @@ dependencies {
testImplementation(projects.features.poll.test) testImplementation(projects.features.poll.test)
testImplementation(projects.features.poll.impl) testImplementation(projects.features.poll.impl)
testImplementation(libs.androidx.compose.ui.test.junit) testImplementation(libs.androidx.compose.ui.test.junit)
testImplementation(projects.libraries.eventformatter.test)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest) testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
} }

View file

@ -73,6 +73,7 @@ import io.element.android.features.messages.impl.messagecomposer.AttachmentsStat
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
import io.element.android.features.messages.impl.messagecomposer.MessageComposerView import io.element.android.features.messages.impl.messagecomposer.MessageComposerView
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerView import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerView
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelineView import io.element.android.features.messages.impl.timeline.TimelineView
import io.element.android.features.messages.impl.timeline.components.JoinCallMenuItem import io.element.android.features.messages.impl.timeline.components.JoinCallMenuItem
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet
@ -401,6 +402,9 @@ private fun MessagesViewContent(
) { ) {
PinnedMessagesBannerView( PinnedMessagesBannerView(
state = state.pinnedMessagesBannerState, state = state.pinnedMessagesBannerState,
onClick = { pinnedEventId ->
//state.timelineState.eventSink(TimelineEvents.FocusOnEvent(pinnedEventId))
},
) )
} }
} }

View file

@ -22,9 +22,9 @@ import dagger.Module
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerPresenter import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerPresenter
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.RoomScope
@ContributesTo(SessionScope::class) @ContributesTo(RoomScope::class)
@Module @Module
interface MessagesModule { interface MessagesModule {
@Binds @Binds

View file

@ -0,0 +1,25 @@
/*
* 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.banner
import androidx.compose.ui.text.AnnotatedString
import io.element.android.libraries.matrix.api.core.EventId
data class PinnedMessagesBannerItem(
val eventId: EventId,
val formatted: AnnotatedString,
)

View file

@ -17,28 +17,86 @@
package io.element.android.features.messages.impl.pinned.banner package io.element.android.features.messages.impl.pinned.banner
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.text.AnnotatedString
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.eventformatter.api.PinnedMessagesBannerFormatter
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.flowOn
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 javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
class PinnedMessagesBannerPresenter @Inject constructor() : Presenter<PinnedMessagesBannerState> { class PinnedMessagesBannerPresenter @Inject constructor(
private val room: MatrixRoom,
private val pinnedMessagesBannerFormatter: PinnedMessagesBannerFormatter,
) : Presenter<PinnedMessagesBannerState> {
@OptIn(FlowPreview::class)
@Composable @Composable
override fun present(): PinnedMessagesBannerState { override fun present(): PinnedMessagesBannerState {
var pinnedMessageCount by remember { var pinnedMessages by remember {
mutableIntStateOf(0) mutableStateOf<List<PinnedMessagesBannerItem>>(emptyList())
} }
var currentPinnedMessageIndex by rememberSaveable { var currentPinnedMessageIndex by rememberSaveable {
mutableIntStateOf(0) mutableIntStateOf(0)
} }
LaunchedEffect(pinnedMessages) {
val pinnedMessageCount = pinnedMessages.size
if (currentPinnedMessageIndex >= pinnedMessageCount) {
currentPinnedMessageIndex = (pinnedMessageCount - 1).coerceAtLeast(0)
}
}
LaunchedEffect(Unit) {
val pinnedEventsTimeline = room.pinnedEventsTimeline().getOrNull() ?: return@LaunchedEffect
pinnedEventsTimeline.timelineItems
.debounce(300.milliseconds)
.map { timelineItems ->
timelineItems.mapNotNull { timelineItem ->
when (timelineItem) {
is MatrixTimelineItem.Event -> {
val eventId = timelineItem.eventId ?: return@mapNotNull null
val formatted = pinnedMessagesBannerFormatter.format(timelineItem.event)
PinnedMessagesBannerItem(
eventId = eventId,
formatted = if (formatted is AnnotatedString) {
formatted
} else {
AnnotatedString(formatted.toString())
},
)
}
else -> null
}
}
}
.flowOn(Dispatchers.Default)
.onEach { newPinnedMessages ->
pinnedMessages = newPinnedMessages
}.onCompletion {
pinnedEventsTimeline.close()
}
.launchIn(this)
}
fun handleEvent(event: PinnedMessagesBannerEvents) { fun handleEvent(event: PinnedMessagesBannerEvents) {
when (event) { when (event) {
is PinnedMessagesBannerEvents.MoveToNextPinned -> { is PinnedMessagesBannerEvents.MoveToNextPinned -> {
if (currentPinnedMessageIndex < pinnedMessageCount - 1) { if (currentPinnedMessageIndex < pinnedMessages.size - 1) {
currentPinnedMessageIndex++ currentPinnedMessageIndex++
} else { } else {
currentPinnedMessageIndex = 0 currentPinnedMessageIndex = 0
@ -48,7 +106,8 @@ class PinnedMessagesBannerPresenter @Inject constructor() : Presenter<PinnedMess
} }
return PinnedMessagesBannerState( return PinnedMessagesBannerState(
pinnedMessagesCount = pinnedMessageCount, pinnedMessagesCount = pinnedMessages.size,
currentPinnedMessage = pinnedMessages.getOrNull(currentPinnedMessageIndex),
currentPinnedMessageIndex = currentPinnedMessageIndex, currentPinnedMessageIndex = currentPinnedMessageIndex,
eventSink = ::handleEvent eventSink = ::handleEvent
) )

View file

@ -19,7 +19,8 @@ package io.element.android.features.messages.impl.pinned.banner
data class PinnedMessagesBannerState( data class PinnedMessagesBannerState(
val pinnedMessagesCount: Int, val pinnedMessagesCount: Int,
val currentPinnedMessageIndex: Int, val currentPinnedMessageIndex: Int,
val currentPinnedMessage: PinnedMessagesBannerItem?,
val eventSink: (PinnedMessagesBannerEvents) -> Unit val eventSink: (PinnedMessagesBannerEvents) -> Unit
) { ) {
val displayBanner = pinnedMessagesCount > 0 && currentPinnedMessageIndex < pinnedMessagesCount val displayBanner = pinnedMessagesCount > 0 && currentPinnedMessage != null
} }

View file

@ -16,7 +16,10 @@
package io.element.android.features.messages.impl.pinned.banner package io.element.android.features.messages.impl.pinned.banner
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.core.EventId
import kotlin.random.Random
internal class PinnedMessagesBannerStateProvider : PreviewParameterProvider<PinnedMessagesBannerState> { internal class PinnedMessagesBannerStateProvider : PreviewParameterProvider<PinnedMessagesBannerState> {
override val values: Sequence<PinnedMessagesBannerState> override val values: Sequence<PinnedMessagesBannerState>
@ -33,9 +36,14 @@ internal class PinnedMessagesBannerStateProvider : PreviewParameterProvider<Pinn
internal fun aPinnedMessagesBannerState( internal fun aPinnedMessagesBannerState(
pinnedMessagesCount: Int = 0, pinnedMessagesCount: Int = 0,
currentPinnedMessageIndex: Int = -1, currentPinnedMessageIndex: Int = -1,
currentPinnedMessage: PinnedMessagesBannerItem = PinnedMessagesBannerItem(
eventId = EventId("\$" + Random.nextInt().toString()),
formatted = AnnotatedString("This is a pinned message")
),
eventSink: (PinnedMessagesBannerEvents) -> Unit = {} eventSink: (PinnedMessagesBannerEvents) -> Unit = {}
) = PinnedMessagesBannerState( ) = PinnedMessagesBannerState(
pinnedMessagesCount = pinnedMessagesCount, pinnedMessagesCount = pinnedMessagesCount,
currentPinnedMessageIndex = currentPinnedMessageIndex, currentPinnedMessageIndex = currentPinnedMessageIndex,
currentPinnedMessage = currentPinnedMessage,
eventSink = eventSink eventSink = eventSink
) )

View file

@ -16,6 +16,7 @@
package io.element.android.features.messages.impl.pinned.banner package io.element.android.features.messages.impl.pinned.banner
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement.spacedBy import androidx.compose.foundation.layout.Arrangement.spacedBy
@ -32,8 +33,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -42,6 +41,7 @@ import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -55,13 +55,17 @@ import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.pinnedMessageBannerBorder import io.element.android.libraries.designsystem.theme.pinnedMessageBannerBorder
import io.element.android.libraries.designsystem.theme.pinnedMessageBannerIndicator import io.element.android.libraries.designsystem.theme.pinnedMessageBannerIndicator
import io.element.android.libraries.designsystem.utils.annotatedTextWithBold import io.element.android.libraries.designsystem.utils.annotatedTextWithBold
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
fun PinnedMessagesBannerView( fun PinnedMessagesBannerView(
state: PinnedMessagesBannerState, state: PinnedMessagesBannerState,
onClick: (EventId) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
if (state.currentPinnedMessage == null) return
val borderColor = ElementTheme.colors.pinnedMessageBannerBorder val borderColor = ElementTheme.colors.pinnedMessageBannerBorder
Row( Row(
modifier = modifier modifier = modifier
@ -86,6 +90,7 @@ fun PinnedMessagesBannerView(
.shadow(elevation = 5.dp, spotColor = Color.Transparent) .shadow(elevation = 5.dp, spotColor = Color.Transparent)
.heightIn(min = 64.dp) .heightIn(min = 64.dp)
.clickable { .clickable {
onClick(state.currentPinnedMessage.eventId)
state.eventSink(PinnedMessagesBannerEvents.MoveToNextPinned) state.eventSink(PinnedMessagesBannerEvents.MoveToNextPinned)
}, },
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@ -106,7 +111,7 @@ fun PinnedMessagesBannerView(
PinnedMessageItem( PinnedMessageItem(
index = state.currentPinnedMessageIndex, index = state.currentPinnedMessageIndex,
totalCount = state.pinnedMessagesCount, totalCount = state.pinnedMessagesCount,
message = "This is a pinned message", message = state.currentPinnedMessage.formatted,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
TextButton(text = stringResource(id = CommonStrings.screen_room_pinned_banner_view_all_button_title), onClick = { /*TODO*/ }) TextButton(text = stringResource(id = CommonStrings.screen_room_pinned_banner_view_all_button_title), onClick = { /*TODO*/ })
@ -119,8 +124,7 @@ private fun PinIndicators(
pinsCount: Int, pinsCount: Int,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val indicatorHeight by remember { val indicatorHeight = remember(pinsCount) {
derivedStateOf {
when (pinsCount) { when (pinsCount) {
0 -> 0 0 -> 0
1 -> 32 1 -> 32
@ -128,7 +132,6 @@ private fun PinIndicators(
else -> 11 else -> 11
} }
} }
}
val lazyListState = rememberLazyListState() val lazyListState = rememberLazyListState()
LaunchedEffect(pinIndex) { LaunchedEffect(pinIndex) {
val viewportSize = lazyListState.layoutInfo.viewportSize val viewportSize = lazyListState.layoutInfo.viewportSize
@ -141,7 +144,8 @@ private fun PinIndicators(
modifier = modifier, modifier = modifier,
state = lazyListState, state = lazyListState,
verticalArrangement = spacedBy(2.dp), verticalArrangement = spacedBy(2.dp),
userScrollEnabled = false userScrollEnabled = false,
reverseLayout = true
) { ) {
items(pinsCount) { index -> items(pinsCount) { index ->
Box( Box(
@ -164,13 +168,13 @@ private fun PinIndicators(
private fun PinnedMessageItem( private fun PinnedMessageItem(
index: Int, index: Int,
totalCount: Int, totalCount: Int,
message: String, message: AnnotatedString,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val countMessage = stringResource(id = CommonStrings.screen_room_pinned_banner_indicator, index + 1, totalCount) val countMessage = stringResource(id = CommonStrings.screen_room_pinned_banner_indicator, index + 1, totalCount)
val fullCountMessage = stringResource(id = CommonStrings.screen_room_pinned_banner_indicator_description, countMessage) val fullCountMessage = stringResource(id = CommonStrings.screen_room_pinned_banner_indicator_description, countMessage)
Column(modifier = modifier) { Column(modifier = modifier) {
if (totalCount > 1) { AnimatedVisibility (totalCount > 1) {
Text( Text(
text = annotatedTextWithBold( text = annotatedTextWithBold(
text = fullCountMessage, text = fullCountMessage,
@ -179,6 +183,7 @@ private fun PinnedMessageItem(
style = ElementTheme.typography.fontBodySmMedium, style = ElementTheme.typography.fontBodySmMedium,
color = ElementTheme.colors.textActionAccent, color = ElementTheme.colors.textActionAccent,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis,
) )
} }
Text( Text(
@ -196,5 +201,6 @@ private fun PinnedMessageItem(
internal fun PinnedMessagesBannerViewPreview(@PreviewParameter(PinnedMessagesBannerStateProvider::class) state: PinnedMessagesBannerState) = ElementPreview { internal fun PinnedMessagesBannerViewPreview(@PreviewParameter(PinnedMessagesBannerStateProvider::class) state: PinnedMessagesBannerState) = ElementPreview {
PinnedMessagesBannerView( PinnedMessagesBannerView(
state = state, state = state,
onClick = {},
) )
} }

View file

@ -17,6 +17,10 @@
package io.element.android.features.messages.impl.pinned.banner package io.element.android.features.messages.impl.pinned.banner
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.eventformatter.api.PinnedMessagesBannerFormatter
import io.element.android.libraries.eventformatter.test.FakePinnedMessagesBannerFormatter
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -29,6 +33,7 @@ class PinnedMessagesBannerPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.pinnedMessagesCount).isEqualTo(0) assertThat(initialState.pinnedMessagesCount).isEqualTo(0)
assertThat(initialState.currentPinnedMessageIndex).isEqualTo(0) assertThat(initialState.currentPinnedMessageIndex).isEqualTo(0)
assertThat(initialState.currentPinnedMessage).isNull()
} }
} }
@ -43,7 +48,15 @@ class PinnedMessagesBannerPresenterTest {
} }
} }
private fun createPinnedMessagesBannerPresenter(): PinnedMessagesBannerPresenter { private fun createPinnedMessagesBannerPresenter(
return PinnedMessagesBannerPresenter() room: MatrixRoom = FakeMatrixRoom(),
formatter: PinnedMessagesBannerFormatter = FakePinnedMessagesBannerFormatter(
formatLambda = { event -> "Content ${event.content}" }
)
): PinnedMessagesBannerPresenter {
return PinnedMessagesBannerPresenter(
room = room,
pinnedMessagesBannerFormatter = formatter
)
} }
} }

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.libraries.eventformatter.test
import io.element.android.libraries.eventformatter.api.PinnedMessagesBannerFormatter
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
class FakePinnedMessagesBannerFormatter(
val formatLambda: (event: EventTimelineItem) -> CharSequence
) : PinnedMessagesBannerFormatter {
override fun format(event: EventTimelineItem): CharSequence {
return formatLambda(event)
}
}