Browse Source

Merge pull request #3259 from element-hq/feature/fga/pinned_message_banner_ui

WIP Pinned events : start creating the banner ui, no logic.
pull/3263/head
ganfra 2 months ago committed by GitHub
parent
commit
0bcfffdddf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 4
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
  2. 2
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt
  3. 10
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt
  4. 50
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
  5. 32
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesModule.kt
  6. 21
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerEvents.kt
  7. 56
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerPresenter.kt
  8. 25
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerState.kt
  9. 41
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerStateProvider.kt
  10. 200
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerView.kt
  11. 2
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt
  12. 37
      features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt
  13. 49
      features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerPresenterTest.kt
  14. 8
      libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt
  15. 46
      libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/LazyListState.kt
  16. 3
      tests/uitests/src/test/snapshots/images/features.messages.impl.pinned.banner_PinnedMessagesBannerView_Day_0_en.png
  17. 3
      tests/uitests/src/test/snapshots/images/features.messages.impl.pinned.banner_PinnedMessagesBannerView_Day_1_en.png
  18. 3
      tests/uitests/src/test/snapshots/images/features.messages.impl.pinned.banner_PinnedMessagesBannerView_Day_2_en.png
  19. 3
      tests/uitests/src/test/snapshots/images/features.messages.impl.pinned.banner_PinnedMessagesBannerView_Day_3_en.png
  20. 3
      tests/uitests/src/test/snapshots/images/features.messages.impl.pinned.banner_PinnedMessagesBannerView_Day_4_en.png
  21. 3
      tests/uitests/src/test/snapshots/images/features.messages.impl.pinned.banner_PinnedMessagesBannerView_Day_5_en.png
  22. 3
      tests/uitests/src/test/snapshots/images/features.messages.impl.pinned.banner_PinnedMessagesBannerView_Night_0_en.png
  23. 3
      tests/uitests/src/test/snapshots/images/features.messages.impl.pinned.banner_PinnedMessagesBannerView_Night_1_en.png
  24. 3
      tests/uitests/src/test/snapshots/images/features.messages.impl.pinned.banner_PinnedMessagesBannerView_Night_2_en.png
  25. 3
      tests/uitests/src/test/snapshots/images/features.messages.impl.pinned.banner_PinnedMessagesBannerView_Night_3_en.png
  26. 3
      tests/uitests/src/test/snapshots/images/features.messages.impl.pinned.banner_PinnedMessagesBannerView_Night_4_en.png
  27. 3
      tests/uitests/src/test/snapshots/images/features.messages.impl.pinned.banner_PinnedMessagesBannerView_Night_5_en.png
  28. 3
      tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_13_en.png
  29. 3
      tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_13_en.png

4
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt

@ -41,6 +41,7 @@ import io.element.android.features.messages.impl.actionlist.model.TimelineItemAc @@ -41,6 +41,7 @@ import io.element.android.features.messages.impl.actionlist.model.TimelineItemAc
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
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
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.TimelinePresenter
@ -101,6 +102,7 @@ class MessagesPresenter @AssistedInject constructor( @@ -101,6 +102,7 @@ class MessagesPresenter @AssistedInject constructor(
private val customReactionPresenter: CustomReactionPresenter,
private val reactionSummaryPresenter: ReactionSummaryPresenter,
private val readReceiptBottomSheetPresenter: ReadReceiptBottomSheetPresenter,
private val pinnedMessagesBannerPresenter: Presenter<PinnedMessagesBannerState>,
private val networkMonitor: NetworkMonitor,
private val snackbarDispatcher: SnackbarDispatcher,
private val dispatchers: CoroutineDispatchers,
@ -132,6 +134,7 @@ class MessagesPresenter @AssistedInject constructor( @@ -132,6 +134,7 @@ class MessagesPresenter @AssistedInject constructor(
val customReactionState = customReactionPresenter.present()
val reactionSummaryState = reactionSummaryPresenter.present()
val readReceiptBottomSheetState = readReceiptBottomSheetPresenter.present()
val pinnedMessagesBannerState = pinnedMessagesBannerPresenter.present()
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
@ -230,6 +233,7 @@ class MessagesPresenter @AssistedInject constructor( @@ -230,6 +233,7 @@ class MessagesPresenter @AssistedInject constructor(
enableVoiceMessages = enableVoiceMessages,
appName = buildMeta.applicationName,
callState = callState,
pinnedMessagesBannerState = pinnedMessagesBannerState,
eventSink = { handleEvents(it) }
)
}

2
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt

@ -19,6 +19,7 @@ package io.element.android.features.messages.impl @@ -19,6 +19,7 @@ package io.element.android.features.messages.impl
import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
import io.element.android.features.messages.impl.timeline.TimelineState
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
@ -54,6 +55,7 @@ data class MessagesState( @@ -54,6 +55,7 @@ data class MessagesState(
val enableVoiceMessages: Boolean,
val callState: RoomCallState,
val appName: String,
val pinnedMessagesBannerState: PinnedMessagesBannerState,
val eventSink: (MessagesEvents) -> Unit
)

10
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt

@ -22,6 +22,8 @@ import io.element.android.features.messages.impl.actionlist.anActionListState @@ -22,6 +22,8 @@ import io.element.android.features.messages.impl.actionlist.anActionListState
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
import io.element.android.features.messages.impl.pinned.banner.aPinnedMessagesBannerState
import io.element.android.features.messages.impl.timeline.TimelineState
import io.element.android.features.messages.impl.timeline.aTimelineItemList
import io.element.android.features.messages.impl.timeline.aTimelineState
@ -87,6 +89,12 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> { @@ -87,6 +89,12 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
aMessagesState(
callState = RoomCallState.DISABLED,
),
aMessagesState(
pinnedMessagesBannerState = aPinnedMessagesBannerState(
pinnedMessagesCount = 4,
currentPinnedMessageIndex = 0,
),
),
)
}
@ -113,6 +121,7 @@ fun aMessagesState( @@ -113,6 +121,7 @@ fun aMessagesState(
showReinvitePrompt: Boolean = false,
enableVoiceMessages: Boolean = true,
callState: RoomCallState = RoomCallState.ENABLED,
pinnedMessagesBannerState: PinnedMessagesBannerState = aPinnedMessagesBannerState(),
eventSink: (MessagesEvents) -> Unit = {},
) = MessagesState(
roomId = RoomId("!id:domain"),
@ -136,6 +145,7 @@ fun aMessagesState( @@ -136,6 +145,7 @@ fun aMessagesState(
enableVoiceMessages = enableVoiceMessages,
callState = callState,
appName = "Element",
pinnedMessagesBannerState = pinnedMessagesBannerState,
eventSink = eventSink,
)

50
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt

@ -16,6 +16,9 @@ @@ -16,6 +16,9 @@
package io.element.android.features.messages.impl
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@ -33,6 +36,7 @@ import androidx.compose.foundation.layout.navigationBarsPadding @@ -33,6 +36,7 @@ import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
@ -68,6 +72,7 @@ import io.element.android.features.messages.impl.messagecomposer.AttachmentsBott @@ -68,6 +72,7 @@ import io.element.android.features.messages.impl.messagecomposer.AttachmentsBott
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
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.TimelineView
import io.element.android.features.messages.impl.timeline.components.JoinCallMenuItem
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet
@ -100,6 +105,7 @@ import io.element.android.libraries.designsystem.theme.components.Text @@ -100,6 +105,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.KeepScreenOn
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.designsystem.utils.isScrollingUp
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.core.UserId
@ -370,22 +376,34 @@ private fun MessagesViewContent( @@ -370,22 +376,34 @@ private fun MessagesViewContent(
RectangleShape
},
content = { paddingValues ->
TimelineView(
state = state.timelineState,
typingNotificationState = state.typingNotificationState,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
onMessageClick = onMessageClick,
onMessageLongClick = onMessageLongClick,
onSwipeToReply = onSwipeToReply,
onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick,
modifier = Modifier.padding(paddingValues),
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
onJoinCallClick = onJoinCallClick,
)
Box(modifier = Modifier.padding(paddingValues)) {
val timelineLazyListState = rememberLazyListState()
TimelineView(
state = state.timelineState,
typingNotificationState = state.typingNotificationState,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
onMessageClick = onMessageClick,
onMessageLongClick = onMessageLongClick,
onSwipeToReply = onSwipeToReply,
onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick,
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
onJoinCallClick = onJoinCallClick,
lazyListState = timelineLazyListState,
)
AnimatedVisibility(
visible = state.pinnedMessagesBannerState.displayBanner && timelineLazyListState.isScrollingUp(),
enter = expandVertically(),
exit = shrinkVertically(),
) {
PinnedMessagesBannerView(
state = state.pinnedMessagesBannerState,
)
}
}
},
sheetContent = { subcomposing: Boolean ->
MessagesViewComposerBottomSheetContents(

32
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesModule.kt

@ -0,0 +1,32 @@ @@ -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.di
import com.squareup.anvil.annotations.ContributesTo
import dagger.Binds
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
@ContributesTo(SessionScope::class)
@Module
interface MessagesModule {
@Binds
fun bindPinnedMessagesBannerPresenter(presenter: PinnedMessagesBannerPresenter): Presenter<PinnedMessagesBannerState>
}

21
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerEvents.kt

@ -0,0 +1,21 @@ @@ -0,0 +1,21 @@
/*
* 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
sealed interface PinnedMessagesBannerEvents {
data object MoveToNextPinned : PinnedMessagesBannerEvents
}

56
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerPresenter.kt

@ -0,0 +1,56 @@ @@ -0,0 +1,56 @@
/*
* 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.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import io.element.android.libraries.architecture.Presenter
import javax.inject.Inject
class PinnedMessagesBannerPresenter @Inject constructor() : Presenter<PinnedMessagesBannerState> {
@Composable
override fun present(): PinnedMessagesBannerState {
var pinnedMessageCount by remember {
mutableIntStateOf(0)
}
var currentPinnedMessageIndex by rememberSaveable {
mutableIntStateOf(0)
}
fun handleEvent(event: PinnedMessagesBannerEvents) {
when (event) {
is PinnedMessagesBannerEvents.MoveToNextPinned -> {
if (currentPinnedMessageIndex < pinnedMessageCount - 1) {
currentPinnedMessageIndex++
} else {
currentPinnedMessageIndex = 0
}
}
}
}
return PinnedMessagesBannerState(
pinnedMessagesCount = pinnedMessageCount,
currentPinnedMessageIndex = currentPinnedMessageIndex,
eventSink = ::handleEvent
)
}
}

25
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerState.kt

@ -0,0 +1,25 @@ @@ -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
data class PinnedMessagesBannerState(
val pinnedMessagesCount: Int,
val currentPinnedMessageIndex: Int,
val eventSink: (PinnedMessagesBannerEvents) -> Unit
) {
val displayBanner = pinnedMessagesCount > 0 && currentPinnedMessageIndex < pinnedMessagesCount
}

41
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerStateProvider.kt

@ -0,0 +1,41 @@ @@ -0,0 +1,41 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.pinned.banner
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
internal class PinnedMessagesBannerStateProvider : PreviewParameterProvider<PinnedMessagesBannerState> {
override val values: Sequence<PinnedMessagesBannerState>
get() = sequenceOf(
aPinnedMessagesBannerState(pinnedMessagesCount = 1, currentPinnedMessageIndex = 0),
aPinnedMessagesBannerState(pinnedMessagesCount = 2, currentPinnedMessageIndex = 0),
aPinnedMessagesBannerState(pinnedMessagesCount = 4, currentPinnedMessageIndex = 0),
aPinnedMessagesBannerState(pinnedMessagesCount = 4, currentPinnedMessageIndex = 1),
aPinnedMessagesBannerState(pinnedMessagesCount = 4, currentPinnedMessageIndex = 2),
aPinnedMessagesBannerState(pinnedMessagesCount = 4, currentPinnedMessageIndex = 3),
)
}
internal fun aPinnedMessagesBannerState(
pinnedMessagesCount: Int = 0,
currentPinnedMessageIndex: Int = -1,
eventSink: (PinnedMessagesBannerEvents) -> Unit = {}
) = PinnedMessagesBannerState(
pinnedMessagesCount = pinnedMessagesCount,
currentPinnedMessageIndex = currentPinnedMessageIndex,
eventSink = eventSink
)

200
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerView.kt

@ -0,0 +1,200 @@ @@ -0,0 +1,200 @@
/*
* 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.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
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
import androidx.compose.ui.draw.drawBehind
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.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
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.ui.strings.CommonStrings
@Composable
fun PinnedMessagesBannerView(
state: PinnedMessagesBannerState,
modifier: Modifier = Modifier,
) {
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)
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = spacedBy(10.dp)
) {
Spacer(modifier = Modifier.width(16.dp))
PinIndicators(
pinIndex = state.currentPinnedMessageIndex,
pinsCount = state.pinnedMessagesCount,
modifier = Modifier.heightIn(max = 40.dp)
)
Icon(
imageVector = CompoundIcons.PinSolid(),
contentDescription = null,
tint = ElementTheme.materialColors.secondary,
modifier = Modifier.size(20.dp)
)
PinnedMessageItem(
index = state.currentPinnedMessageIndex,
totalCount = state.pinnedMessagesCount,
message = "This is a pinned message",
modifier = Modifier.weight(1f)
)
TextButton(text = stringResource(id = CommonStrings.screen_room_pinned_banner_view_all_button_title), onClick = { /*TODO*/ })
}
}
@Composable
private fun PinIndicators(
pinIndex: Int,
pinsCount: Int,
modifier: Modifier = Modifier,
) {
val indicatorHeight by remember {
derivedStateOf {
when (pinsCount) {
0 -> 0
1 -> 32
2 -> 18
else -> 11
}
}
}
val lazyListState = rememberLazyListState()
LaunchedEffect(pinIndex) {
val viewportSize = lazyListState.layoutInfo.viewportSize
lazyListState.animateScrollToItem(
pinIndex,
indicatorHeight / 2 - viewportSize.height / 2
)
}
LazyColumn(
modifier = modifier,
state = lazyListState,
verticalArrangement = spacedBy(2.dp),
userScrollEnabled = false
) {
items(pinsCount) { index ->
Box(
modifier = Modifier
.width(2.dp)
.height(indicatorHeight.dp)
.background(
color = if (index == pinIndex) {
ElementTheme.colors.iconAccentPrimary
} else {
ElementTheme.colors.pinnedMessageBannerIndicator
}
)
)
}
}
}
@Composable
private fun PinnedMessageItem(
index: Int,
totalCount: Int,
message: String,
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) {
Text(
text = annotatedTextWithBold(
text = fullCountMessage,
boldText = countMessage,
),
style = ElementTheme.typography.fontBodySmMedium,
color = ElementTheme.colors.textActionAccent,
maxLines = 1,
)
}
Text(
text = message,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textPrimary,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
}
}
@PreviewsDayNight
@Composable
internal fun PinnedMessagesBannerViewPreview(@PreviewParameter(PinnedMessagesBannerStateProvider::class) state: PinnedMessagesBannerState) = ElementPreview {
PinnedMessagesBannerView(
state = state,
)
}

2
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt

@ -90,6 +90,7 @@ fun TimelineView( @@ -90,6 +90,7 @@ fun TimelineView(
onReadReceiptClick: (TimelineItem.Event) -> Unit,
onJoinCallClick: () -> Unit,
modifier: Modifier = Modifier,
lazyListState: LazyListState = rememberLazyListState(),
forceJumpToBottomVisibility: Boolean = false
) {
fun clearFocusRequestState() {
@ -109,7 +110,6 @@ fun TimelineView( @@ -109,7 +110,6 @@ fun TimelineView(
}
val context = LocalContext.current
val lazyListState = rememberLazyListState()
// Disable reverse layout when TalkBack is enabled to avoid incorrect ordering issues seen in the current Compose UI version
val useReverseLayout = remember {
val accessibilityManager = context.getSystemService(AccessibilityManager::class.java)

37
features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt

@ -30,6 +30,7 @@ import io.element.android.features.messages.impl.fixtures.aMessageEvent @@ -30,6 +30,7 @@ import io.element.android.features.messages.impl.fixtures.aMessageEvent
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactory
import io.element.android.features.messages.impl.messagecomposer.DefaultMessageComposerContext
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
import io.element.android.features.messages.impl.pinned.banner.aPinnedMessagesBannerState
import io.element.android.features.messages.impl.textcomposer.TestRichTextEditorStateFactory
import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.timeline.TimelineItemIndexer
@ -158,7 +159,7 @@ class MessagesPresenterTest { @@ -158,7 +159,7 @@ class MessagesPresenterTest {
canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) },
canUserPinUnpinResult = { Result.success(true) },
)
)
assertThat(room.markAsReadCalls).isEmpty()
val presenter = createMessagesPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
@ -179,7 +180,7 @@ class MessagesPresenterTest { @@ -179,7 +180,7 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) },
canUserPinUnpinResult = { Result.success(true) },
).apply {
).apply {
givenRoomInfo(aRoomInfo(hasRoomCall = true))
}
val presenter = createMessagesPresenter(matrixRoom = room)
@ -208,7 +209,7 @@ class MessagesPresenterTest { @@ -208,7 +209,7 @@ class MessagesPresenterTest {
canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) },
canUserPinUnpinResult = { Result.success(true) },
)
)
val presenter = createMessagesPresenter(matrixRoom = room, coroutineDispatchers = coroutineDispatchers)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@ -246,7 +247,7 @@ class MessagesPresenterTest { @@ -246,7 +247,7 @@ class MessagesPresenterTest {
canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) },
canUserPinUnpinResult = { Result.success(true) },
)
)
val presenter = createMessagesPresenter(matrixRoom = room, coroutineDispatchers = coroutineDispatchers)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@ -305,7 +306,7 @@ class MessagesPresenterTest { @@ -305,7 +306,7 @@ class MessagesPresenterTest {
canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) },
canUserPinUnpinResult = { Result.success(true) },
)
)
val presenter = createMessagesPresenter(
clipboardHelper = clipboardHelper,
matrixRoom = matrixRoom,
@ -495,7 +496,7 @@ class MessagesPresenterTest { @@ -495,7 +496,7 @@ class MessagesPresenterTest {
canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) },
canUserPinUnpinResult = { Result.success(true) },
)
)
val redactEventLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String? -> Result.success(true) }
liveTimeline.redactEventLambda = redactEventLambda
@ -570,7 +571,7 @@ class MessagesPresenterTest { @@ -570,7 +571,7 @@ class MessagesPresenterTest {
canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) },
canUserPinUnpinResult = { Result.success(true) },
)
)
val presenter = createMessagesPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@ -606,7 +607,7 @@ class MessagesPresenterTest { @@ -606,7 +607,7 @@ class MessagesPresenterTest {
canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) },
canUserPinUnpinResult = { Result.success(true) },
)
)
val presenter = createMessagesPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@ -631,7 +632,7 @@ class MessagesPresenterTest { @@ -631,7 +632,7 @@ class MessagesPresenterTest {
canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) },
canUserPinUnpinResult = { Result.success(true) },
)
)
val presenter = createMessagesPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@ -656,7 +657,7 @@ class MessagesPresenterTest { @@ -656,7 +657,7 @@ class MessagesPresenterTest {
canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) },
canUserPinUnpinResult = { Result.success(true) },
)
)
room.givenRoomMembersState(
MatrixRoomMembersState.Ready(
persistentListOf(
@ -692,7 +693,7 @@ class MessagesPresenterTest { @@ -692,7 +693,7 @@ class MessagesPresenterTest {
canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) },
canUserPinUnpinResult = { Result.success(true) },
)
)
room.givenRoomMembersState(
MatrixRoomMembersState.Error(
failure = Throwable(),
@ -729,7 +730,7 @@ class MessagesPresenterTest { @@ -729,7 +730,7 @@ class MessagesPresenterTest {
canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) },
canUserPinUnpinResult = { Result.success(true) },
)
)
room.givenRoomMembersState(MatrixRoomMembersState.Unknown)
val presenter = createMessagesPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
@ -756,7 +757,7 @@ class MessagesPresenterTest { @@ -756,7 +757,7 @@ class MessagesPresenterTest {
canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) },
canUserPinUnpinResult = { Result.success(true) },
)
)
room.givenRoomMembersState(
MatrixRoomMembersState.Ready(
persistentListOf(
@ -797,7 +798,7 @@ class MessagesPresenterTest { @@ -797,7 +798,7 @@ class MessagesPresenterTest {
canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) },
canUserPinUnpinResult = { Result.success(true) },
)
)
val presenter = createMessagesPresenter(matrixRoom = matrixRoom)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@ -822,7 +823,7 @@ class MessagesPresenterTest { @@ -822,7 +823,7 @@ class MessagesPresenterTest {
canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) },
canUserPinUnpinResult = { Result.success(true) },
)
)
val presenter = createMessagesPresenter(matrixRoom = matrixRoom)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@ -844,7 +845,7 @@ class MessagesPresenterTest { @@ -844,7 +845,7 @@ class MessagesPresenterTest {
canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) },
canUserPinUnpinResult = { Result.success(true) },
)
)
val presenter = createMessagesPresenter(matrixRoom = matrixRoom)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@ -865,7 +866,7 @@ class MessagesPresenterTest { @@ -865,7 +866,7 @@ class MessagesPresenterTest {
canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) },
canUserPinUnpinResult = { Result.success(true) },
)
)
val presenter = createMessagesPresenter(matrixRoom = matrixRoom)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@ -1060,6 +1061,7 @@ class MessagesPresenterTest { @@ -1060,6 +1061,7 @@ class MessagesPresenterTest {
val readReceiptBottomSheetPresenter = ReadReceiptBottomSheetPresenter()
val customReactionPresenter = CustomReactionPresenter(emojibaseProvider = FakeEmojibaseProvider())
val reactionSummaryPresenter = ReactionSummaryPresenter(room = matrixRoom)
return MessagesPresenter(
room = matrixRoom,
composerPresenter = messageComposerPresenter,
@ -1070,6 +1072,7 @@ class MessagesPresenterTest { @@ -1070,6 +1072,7 @@ class MessagesPresenterTest {
customReactionPresenter = customReactionPresenter,
reactionSummaryPresenter = reactionSummaryPresenter,
readReceiptBottomSheetPresenter = readReceiptBottomSheetPresenter,
pinnedMessagesBannerPresenter = { aPinnedMessagesBannerState() },
networkMonitor = FakeNetworkMonitor(),
snackbarDispatcher = SnackbarDispatcher(),
navigator = navigator,

49
features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerPresenterTest.kt

@ -0,0 +1,49 @@ @@ -0,0 +1,49 @@
/*
* 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 com.google.common.truth.Truth.assertThat
import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest
import org.junit.Test
class PinnedMessagesBannerPresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = createPinnedMessagesBannerPresenter()
presenter.test {
val initialState = awaitItem()
assertThat(initialState.pinnedMessagesCount).isEqualTo(0)
assertThat(initialState.currentPinnedMessageIndex).isEqualTo(0)
}
}
@Test
fun `present - move to next pinned message when there is no pinned events`() = runTest {
val presenter = createPinnedMessagesBannerPresenter()
presenter.test {
val initialState = awaitItem()
initialState.eventSink(PinnedMessagesBannerEvents.MoveToNextPinned)
// Nothing is emitted
ensureAllEventsConsumed()
}
}
private fun createPinnedMessagesBannerPresenter(): PinnedMessagesBannerPresenter {
return PinnedMessagesBannerPresenter()
}
}

8
libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt

@ -179,6 +179,14 @@ val SemanticColors.badgeNegativeBackgroundColor @@ -179,6 +179,14 @@ val SemanticColors.badgeNegativeBackgroundColor
val SemanticColors.badgeNegativeContentColor
get() = if (isLight) LightColorTokens.colorRed1100 else DarkColorTokens.colorRed1100
@OptIn(CoreColorToken::class)
val SemanticColors.pinnedMessageBannerIndicator
get() = if (isLight) LightColorTokens.colorAlphaGray600 else DarkColorTokens.colorAlphaGray600
@OptIn(CoreColorToken::class)
val SemanticColors.pinnedMessageBannerBorder
get() = if (isLight) LightColorTokens.colorAlphaGray400 else DarkColorTokens.colorAlphaGray400
@PreviewsDayNight
@Composable
internal fun ColorAliasesPreview() = ElementPreview {

46
libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/LazyListState.kt

@ -0,0 +1,46 @@ @@ -0,0 +1,46 @@
/*
* 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.designsystem.utils
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
/**
* Returns whether the lazy list is currently scrolling up.
*/
@Composable
fun LazyListState.isScrollingUp(): Boolean {
var previousIndex by remember(this) { mutableIntStateOf(firstVisibleItemIndex) }
var previousScrollOffset by remember(this) { mutableIntStateOf(firstVisibleItemScrollOffset) }
return remember(this) {
derivedStateOf {
if (previousIndex != firstVisibleItemIndex) {
previousIndex > firstVisibleItemIndex
} else {
previousScrollOffset >= firstVisibleItemScrollOffset
}.also {
previousIndex = firstVisibleItemIndex
previousScrollOffset = firstVisibleItemScrollOffset
}
}
}.value
}

3
tests/uitests/src/test/snapshots/images/features.messages.impl.pinned.banner_PinnedMessagesBannerView_Day_0_en.png

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7114202a1de9860547c525c0dadc110ce9e2e198465218ac2c33cf65f2f0eaa2
size 9496

3
tests/uitests/src/test/snapshots/images/features.messages.impl.pinned.banner_PinnedMessagesBannerView_Day_1_en.png

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8e6b5fd9ecc2b01cc8a83f3fe8e34352de1792a82db85c396377a18246adad1a
size 12953

3
tests/uitests/src/test/snapshots/images/features.messages.impl.pinned.banner_PinnedMessagesBannerView_Day_2_en.png

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:14ca5901134299e801e204e280d731e7de4072f1d522b076eb41c5f806897ed2
size 12905

3
tests/uitests/src/test/snapshots/images/features.messages.impl.pinned.banner_PinnedMessagesBannerView_Day_3_en.png

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bb206284c642dd665290d5d553491e622b8e15a64df7bb2dbd91ea5d3a13e19a
size 13041

3
tests/uitests/src/test/snapshots/images/features.messages.impl.pinned.banner_PinnedMessagesBannerView_Day_4_en.png

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a1940c4ee1e07c6a0198682460af1a6558fcaf14cb69ff061831cb591eb7aec3
size 13066

3
tests/uitests/src/test/snapshots/images/features.messages.impl.pinned.banner_PinnedMessagesBannerView_Day_5_en.png

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:fee5c1dbcdf7929f4762b2915584fe45b7f39916a949663f03e8d7e85e991b4b
size 12988

3
tests/uitests/src/test/snapshots/images/features.messages.impl.pinned.banner_PinnedMessagesBannerView_Night_0_en.png

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:16658495b889654f152ba80a52111164f9682009b96abf1f3f20e660bd7c2407
size 9297

3
tests/uitests/src/test/snapshots/images/features.messages.impl.pinned.banner_PinnedMessagesBannerView_Night_1_en.png

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:43651c4a7f10406f1a6a4b995467ba292d4a43bc198dcf352ae2e66694154de2
size 12340

3
tests/uitests/src/test/snapshots/images/features.messages.impl.pinned.banner_PinnedMessagesBannerView_Night_2_en.png

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cca8ebb1ec12497de7e2efc1725a2e4427eecd1d340ae8176d10f914def0af25
size 12297

3
tests/uitests/src/test/snapshots/images/features.messages.impl.pinned.banner_PinnedMessagesBannerView_Night_3_en.png

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:37bf00fbf548b7ba3d601af1ca489e07e25059c2f5a68abf9b85f4c656cf482c
size 12425

3
tests/uitests/src/test/snapshots/images/features.messages.impl.pinned.banner_PinnedMessagesBannerView_Night_4_en.png

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ed813a7003eb01a06667b10191990ed5bb3f75ee6a447cc4d52510b7e13b3724
size 12448

3
tests/uitests/src/test/snapshots/images/features.messages.impl.pinned.banner_PinnedMessagesBannerView_Night_5_en.png

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6fa556ff7f6757c69c24e47520e08ccfd1b009d8e49a704c36d7fc4ca4186cbf
size 12378

3
tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_13_en.png

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:35761e520dcd7ad01d3913a98479314a1df645bdf141214e3a180fe150d2e8fd
size 59959

3
tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_13_en.png

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:acd2cf24e0a894936d6ed0896fb4ebcdba3529437e595ca54aa13a6f8ee79a5f
size 59327
Loading…
Cancel
Save