diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index 781a42b2fb..c1d3067510 100644 --- a/features/messages/impl/build.gradle.kts +++ b/features/messages/impl/build.gradle.kts @@ -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) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index 3f606f9fb9..962ed0f8ff 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -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)) + }, ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesModule.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesModule.kt index 77fafd0887..dba98edf3d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesModule.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesModule.kt @@ -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 diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerItem.kt new file mode 100644 index 0000000000..19dce8a4a0 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerItem.kt @@ -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, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerPresenter.kt index e020581468..d5968b6a7b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerPresenter.kt @@ -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 { +class PinnedMessagesBannerPresenter @Inject constructor( + private val room: MatrixRoom, + private val pinnedMessagesBannerFormatter: PinnedMessagesBannerFormatter, +) : Presenter { + + @OptIn(FlowPreview::class) @Composable override fun present(): PinnedMessagesBannerState { - var pinnedMessageCount by remember { - mutableIntStateOf(0) + var pinnedMessages by remember { + mutableStateOf>(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 Unit ) { - val displayBanner = pinnedMessagesCount > 0 && currentPinnedMessageIndex < pinnedMessagesCount + val displayBanner = pinnedMessagesCount > 0 && currentPinnedMessage != null } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerStateProvider.kt index 54fa0f12c0..dcd6a4984a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerStateProvider.kt @@ -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 { override val values: Sequence @@ -33,9 +36,14 @@ internal class PinnedMessagesBannerStateProvider : PreviewParameterProvider Unit = {} ) = PinnedMessagesBannerState( pinnedMessagesCount = pinnedMessagesCount, currentPinnedMessageIndex = currentPinnedMessageIndex, + currentPinnedMessage = currentPinnedMessage, eventSink = eventSink ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerView.kt index d6139f5aba..7fe2fa49cd 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerView.kt @@ -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 = {}, ) } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerPresenterTest.kt index 6cf1b8beb0..9ed181d9e1 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerPresenterTest.kt @@ -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 + ) } } diff --git a/libraries/eventformatter/test/src/main/kotlin/io/element/android/libraries/eventformatter/test/FakePinnedMessagesBannerFormatter.kt b/libraries/eventformatter/test/src/main/kotlin/io/element/android/libraries/eventformatter/test/FakePinnedMessagesBannerFormatter.kt new file mode 100644 index 0000000000..668803169e --- /dev/null +++ b/libraries/eventformatter/test/src/main/kotlin/io/element/android/libraries/eventformatter/test/FakePinnedMessagesBannerFormatter.kt @@ -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) + } +}