Pinned events : start branching sdk data in the banner.

This commit is contained in:
ganfra 2024-08-01 18:34:52 +02:00
parent 34fd21f440
commit f63b59e118
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.impl)
testImplementation(libs.androidx.compose.ui.test.junit)
testImplementation(projects.libraries.eventformatter.test)
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.MessageComposerView
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.components.JoinCallMenuItem
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet
@ -401,6 +402,9 @@ private fun MessagesViewContent(
) {
PinnedMessagesBannerView(
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.PinnedMessagesBannerState
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
interface MessagesModule {
@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
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.AnnotatedString
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 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
override fun present(): PinnedMessagesBannerState {
var pinnedMessageCount by remember {
mutableIntStateOf(0)
var pinnedMessages by remember {
mutableStateOf<List<PinnedMessagesBannerItem>>(emptyList())
}
var currentPinnedMessageIndex by rememberSaveable {
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) {
when (event) {
is PinnedMessagesBannerEvents.MoveToNextPinned -> {
if (currentPinnedMessageIndex < pinnedMessageCount - 1) {
if (currentPinnedMessageIndex < pinnedMessages.size - 1) {
currentPinnedMessageIndex++
} else {
currentPinnedMessageIndex = 0
@ -48,7 +106,8 @@ class PinnedMessagesBannerPresenter @Inject constructor() : Presenter<PinnedMess
}
return PinnedMessagesBannerState(
pinnedMessagesCount = pinnedMessageCount,
pinnedMessagesCount = pinnedMessages.size,
currentPinnedMessage = pinnedMessages.getOrNull(currentPinnedMessageIndex),
currentPinnedMessageIndex = currentPinnedMessageIndex,
eventSink = ::handleEvent
)

View file

@ -19,7 +19,8 @@ package io.element.android.features.messages.impl.pinned.banner
data class PinnedMessagesBannerState(
val pinnedMessagesCount: Int,
val currentPinnedMessageIndex: Int,
val currentPinnedMessage: PinnedMessagesBannerItem?,
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
import androidx.compose.ui.text.AnnotatedString
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> {
override val values: Sequence<PinnedMessagesBannerState>
@ -33,9 +36,14 @@ internal class PinnedMessagesBannerStateProvider : PreviewParameterProvider<Pinn
internal fun aPinnedMessagesBannerState(
pinnedMessagesCount: Int = 0,
currentPinnedMessageIndex: Int = -1,
currentPinnedMessage: PinnedMessagesBannerItem = PinnedMessagesBannerItem(
eventId = EventId("\$" + Random.nextInt().toString()),
formatted = AnnotatedString("This is a pinned message")
),
eventSink: (PinnedMessagesBannerEvents) -> Unit = {}
) = PinnedMessagesBannerState(
pinnedMessagesCount = pinnedMessagesCount,
currentPinnedMessageIndex = currentPinnedMessageIndex,
currentPinnedMessage = currentPinnedMessage,
eventSink = eventSink
)

View file

@ -16,6 +16,7 @@
package io.element.android.features.messages.impl.pinned.banner
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
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.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
@ -55,39 +55,44 @@ import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.pinnedMessageBannerBorder
import io.element.android.libraries.designsystem.theme.pinnedMessageBannerIndicator
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
@Composable
fun PinnedMessagesBannerView(
state: PinnedMessagesBannerState,
onClick: (EventId) -> Unit,
modifier: Modifier = Modifier,
) {
if (state.currentPinnedMessage == null) return
val borderColor = ElementTheme.colors.pinnedMessageBannerBorder
Row(
modifier = modifier
.background(color = ElementTheme.colors.bgCanvasDefault)
.fillMaxWidth()
.drawBehind {
val strokeWidth = 0.5.dp.toPx()
val y = size.height - strokeWidth / 2
drawLine(
borderColor,
Offset(0f, y),
Offset(size.width, y),
strokeWidth
)
drawLine(
borderColor,
Offset(0f, 0f),
Offset(size.width, 0f),
strokeWidth
)
}
.shadow(elevation = 5.dp, spotColor = Color.Transparent)
.heightIn(min = 64.dp)
.clickable {
state.eventSink(PinnedMessagesBannerEvents.MoveToNextPinned)
},
.background(color = ElementTheme.colors.bgCanvasDefault)
.fillMaxWidth()
.drawBehind {
val strokeWidth = 0.5.dp.toPx()
val y = size.height - strokeWidth / 2
drawLine(
borderColor,
Offset(0f, y),
Offset(size.width, y),
strokeWidth
)
drawLine(
borderColor,
Offset(0f, 0f),
Offset(size.width, 0f),
strokeWidth
)
}
.shadow(elevation = 5.dp, spotColor = Color.Transparent)
.heightIn(min = 64.dp)
.clickable {
onClick(state.currentPinnedMessage.eventId)
state.eventSink(PinnedMessagesBannerEvents.MoveToNextPinned)
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = spacedBy(10.dp)
) {
@ -106,7 +111,7 @@ fun PinnedMessagesBannerView(
PinnedMessageItem(
index = state.currentPinnedMessageIndex,
totalCount = state.pinnedMessagesCount,
message = "This is a pinned message",
message = state.currentPinnedMessage.formatted,
modifier = Modifier.weight(1f)
)
TextButton(text = stringResource(id = CommonStrings.screen_room_pinned_banner_view_all_button_title), onClick = { /*TODO*/ })
@ -119,14 +124,12 @@ private fun PinIndicators(
pinsCount: Int,
modifier: Modifier = Modifier,
) {
val indicatorHeight by remember {
derivedStateOf {
when (pinsCount) {
0 -> 0
1 -> 32
2 -> 18
else -> 11
}
val indicatorHeight = remember(pinsCount) {
when (pinsCount) {
0 -> 0
1 -> 32
2 -> 18
else -> 11
}
}
val lazyListState = rememberLazyListState()
@ -141,20 +144,21 @@ private fun PinIndicators(
modifier = modifier,
state = lazyListState,
verticalArrangement = spacedBy(2.dp),
userScrollEnabled = false
userScrollEnabled = false,
reverseLayout = true
) {
items(pinsCount) { index ->
Box(
modifier = Modifier
.width(2.dp)
.height(indicatorHeight.dp)
.background(
color = if (index == pinIndex) {
ElementTheme.colors.iconAccentPrimary
} else {
ElementTheme.colors.pinnedMessageBannerIndicator
}
)
.width(2.dp)
.height(indicatorHeight.dp)
.background(
color = if (index == pinIndex) {
ElementTheme.colors.iconAccentPrimary
} else {
ElementTheme.colors.pinnedMessageBannerIndicator
}
)
)
}
}
@ -164,13 +168,13 @@ private fun PinIndicators(
private fun PinnedMessageItem(
index: Int,
totalCount: Int,
message: String,
message: AnnotatedString,
modifier: Modifier = Modifier,
) {
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)
Column(modifier = modifier) {
if (totalCount > 1) {
AnimatedVisibility (totalCount > 1) {
Text(
text = annotatedTextWithBold(
text = fullCountMessage,
@ -179,6 +183,7 @@ private fun PinnedMessageItem(
style = ElementTheme.typography.fontBodySmMedium,
color = ElementTheme.colors.textActionAccent,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Text(
@ -196,5 +201,6 @@ private fun PinnedMessageItem(
internal fun PinnedMessagesBannerViewPreview(@PreviewParameter(PinnedMessagesBannerStateProvider::class) state: PinnedMessagesBannerState) = ElementPreview {
PinnedMessagesBannerView(
state = state,
onClick = {},
)
}

View file

@ -17,6 +17,10 @@
package io.element.android.features.messages.impl.pinned.banner
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 kotlinx.coroutines.test.runTest
import org.junit.Test
@ -29,6 +33,7 @@ class PinnedMessagesBannerPresenterTest {
val initialState = awaitItem()
assertThat(initialState.pinnedMessagesCount).isEqualTo(0)
assertThat(initialState.currentPinnedMessageIndex).isEqualTo(0)
assertThat(initialState.currentPinnedMessage).isNull()
}
}
@ -43,7 +48,15 @@ class PinnedMessagesBannerPresenterTest {
}
}
private fun createPinnedMessagesBannerPresenter(): PinnedMessagesBannerPresenter {
return PinnedMessagesBannerPresenter()
private fun createPinnedMessagesBannerPresenter(
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)
}
}