Pinned events : handle loading/error on banner

This commit is contained in:
ganfra
2024-08-06 15:08:45 +02:00
parent d29ffc653c
commit 4771b25fe3
8 changed files with 238 additions and 97 deletions

View File

@@ -23,7 +23,7 @@ import io.element.android.features.messages.impl.messagecomposer.AttachmentsStat
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.pinned.banner.aLoadedPinnedMessagesBannerState
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
@@ -90,8 +90,8 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
callState = RoomCallState.DISABLED,
),
aMessagesState(
pinnedMessagesBannerState = aPinnedMessagesBannerState(
pinnedMessagesCount = 4,
pinnedMessagesBannerState = aLoadedPinnedMessagesBannerState(
knownPinnedMessagesCount = 4,
currentPinnedMessageIndex = 0,
),
),
@@ -121,7 +121,7 @@ fun aMessagesState(
showReinvitePrompt: Boolean = false,
enableVoiceMessages: Boolean = true,
callState: RoomCallState = RoomCallState.ENABLED,
pinnedMessagesBannerState: PinnedMessagesBannerState = aPinnedMessagesBannerState(),
pinnedMessagesBannerState: PinnedMessagesBannerState = aLoadedPinnedMessagesBannerState(),
eventSink: (MessagesEvents) -> Unit = {},
) = MessagesState(
roomId = RoomId("!id:domain"),

View File

@@ -71,6 +71,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.PinnedMessagesBannerState
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerView
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerViewDefaults
import io.element.android.features.messages.impl.timeline.TimelineEvents
@@ -400,7 +401,7 @@ private fun MessagesViewContent(
nestedScrollConnection = scrollBehavior.nestedScrollConnection,
)
AnimatedVisibility(
visible = state.pinnedMessagesBannerState.displayBanner && scrollBehavior.isVisible,
visible = state.pinnedMessagesBannerState != PinnedMessagesBannerState.Hidden && scrollBehavior.isVisible,
enter = expandVertically(),
exit = shrinkVertically(),
) {

View File

@@ -26,10 +26,14 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.room.MatrixRoom
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn
@@ -43,29 +47,34 @@ class PinnedMessagesBannerPresenter @Inject constructor(
private val room: MatrixRoom,
private val itemFactory: PinnedMessagesBannerItemFactory,
private val featureFlagService: FeatureFlagService,
private val networkMonitor: NetworkMonitor,
) : Presenter<PinnedMessagesBannerState> {
@Composable
override fun present(): PinnedMessagesBannerState {
val isFeatureEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.PinnedEvents).collectAsState(initial = false)
var timelineFailed by rememberSaveable { mutableStateOf(false) }
var pinnedItems by remember {
mutableStateOf<List<PinnedMessagesBannerItem>>(emptyList())
mutableStateOf<ImmutableList<PinnedMessagesBannerItem>>(persistentListOf())
}
val knownPinnedMessagesCount by remember {
room.roomInfoFlow.map { roomInfo -> roomInfo.pinnedEventIds.size }
}.collectAsState(initial = null)
fun onItemsChange(newItems: List<PinnedMessagesBannerItem>) {
pinnedItems = newItems
}
var currentPinnedMessageIndex by rememberSaveable { mutableIntStateOf(0) }
var currentPinnedMessageIndex by rememberSaveable {
mutableIntStateOf(0)
}
LaunchedEffect(pinnedItems) {
val pinnedMessageCount = pinnedItems.size
if (currentPinnedMessageIndex >= pinnedMessageCount) {
currentPinnedMessageIndex = (pinnedMessageCount - 1).coerceAtLeast(0)
PinnedMessagesBannerItemsEffect(
isFeatureEnabled = isFeatureEnabled,
onItemsChange = { newItems ->
val pinnedMessageCount = newItems.size
if (currentPinnedMessageIndex >= pinnedMessageCount) {
currentPinnedMessageIndex = 0
}
pinnedItems = newItems
},
onTimelineFail = { hasTimelineFailed ->
timelineFailed = hasTimelineFailed
}
}
PinnedMessagesBannerItemsEffect(::onItemsChange)
)
fun handleEvent(event: PinnedMessagesBannerEvents) {
when (event) {
@@ -79,32 +88,68 @@ class PinnedMessagesBannerPresenter @Inject constructor(
}
}
return PinnedMessagesBannerState(
pinnedMessagesCount = pinnedItems.size,
currentPinnedMessage = pinnedItems.getOrNull(currentPinnedMessageIndex),
return pinnedMessagesBannerState(
isFeatureEnabled = isFeatureEnabled,
hasTimelineFailed = timelineFailed,
realPinnedMessagesCount = knownPinnedMessagesCount,
pinnedItems = pinnedItems,
currentPinnedMessageIndex = currentPinnedMessageIndex,
eventSink = ::handleEvent
)
}
@Composable
private fun pinnedMessagesBannerState(
isFeatureEnabled: Boolean,
hasTimelineFailed: Boolean,
realPinnedMessagesCount: Int?,
pinnedItems: ImmutableList<PinnedMessagesBannerItem>,
currentPinnedMessageIndex: Int,
eventSink: (PinnedMessagesBannerEvents) -> Unit
): PinnedMessagesBannerState {
val currentPinnedMessage = pinnedItems.getOrNull(currentPinnedMessageIndex)
return when {
!isFeatureEnabled -> PinnedMessagesBannerState.Hidden
hasTimelineFailed -> PinnedMessagesBannerState.Hidden
realPinnedMessagesCount == null || realPinnedMessagesCount == 0 -> PinnedMessagesBannerState.Hidden
currentPinnedMessage == null -> PinnedMessagesBannerState.Loading(realPinnedMessagesCount = realPinnedMessagesCount)
else -> {
PinnedMessagesBannerState.Loaded(
currentPinnedMessage = currentPinnedMessage,
currentPinnedMessageIndex = currentPinnedMessageIndex,
knownPinnedMessagesCount = realPinnedMessagesCount,
eventSink = eventSink
)
}
}
}
@OptIn(FlowPreview::class)
@Composable
private fun PinnedMessagesBannerItemsEffect(
onItemsChange: (List<PinnedMessagesBannerItem>) -> Unit,
isFeatureEnabled: Boolean,
onItemsChange: (ImmutableList<PinnedMessagesBannerItem>) -> Unit,
onTimelineFail: (Boolean) -> Unit,
) {
val isFeatureEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.PinnedEvents).collectAsState(initial = false)
val updatedOnItemsChange by rememberUpdatedState(onItemsChange)
val updatedOnTimelineFail by rememberUpdatedState(onTimelineFail)
val networkStatus by networkMonitor.connectivity.collectAsState()
LaunchedEffect(isFeatureEnabled) {
LaunchedEffect(isFeatureEnabled, networkStatus) {
if (!isFeatureEnabled) return@LaunchedEffect
val pinnedEventsTimeline = room.pinnedEventsTimeline().getOrNull() ?: return@LaunchedEffect
val pinnedEventsTimeline = room.pinnedEventsTimeline()
.onFailure { updatedOnTimelineFail(true) }
.onSuccess { updatedOnTimelineFail(false) }
.getOrNull()
?: return@LaunchedEffect
pinnedEventsTimeline.timelineItems
.debounce(300.milliseconds)
.map { timelineItems ->
timelineItems.mapNotNull { timelineItem ->
itemFactory.create(timelineItem)
}
}.toImmutableList()
}
.onEach { newItems ->
updatedOnItemsChange(newItems)

View File

@@ -16,11 +16,40 @@
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 && currentPinnedMessage != null
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import io.element.android.libraries.designsystem.text.toAnnotatedString
import io.element.android.libraries.ui.strings.CommonStrings
@Immutable
sealed interface PinnedMessagesBannerState {
data object Hidden : PinnedMessagesBannerState
data class Loading(val realPinnedMessagesCount: Int) : PinnedMessagesBannerState
data class Loaded(
val currentPinnedMessage: PinnedMessagesBannerItem,
val currentPinnedMessageIndex: Int,
val knownPinnedMessagesCount: Int,
val eventSink: (PinnedMessagesBannerEvents) -> Unit
) : PinnedMessagesBannerState
fun pinnedMessagesCount() = when (this) {
is Hidden -> 0
is Loading -> realPinnedMessagesCount
is Loaded -> knownPinnedMessagesCount
}
fun currentPinnedMessageIndex() = when (this) {
is Hidden -> 0
is Loading -> 0
is Loaded -> currentPinnedMessageIndex
}
@Composable
fun formattedMessage() = when (this) {
is Hidden -> AnnotatedString("")
is Loading -> stringResource(id = CommonStrings.screen_room_pinned_banner_loading_description).toAnnotatedString()
is Loaded -> currentPinnedMessage.formatted
}
}

View File

@@ -24,26 +24,38 @@ import kotlin.random.Random
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),
aHiddenPinnedMessagesBannerState(),
aLoadingPinnedMessagesBannerState(knownPinnedMessagesCount = 1),
aLoadingPinnedMessagesBannerState(knownPinnedMessagesCount = 4),
aLoadedPinnedMessagesBannerState(knownPinnedMessagesCount = 1, currentPinnedMessageIndex = 0),
aLoadedPinnedMessagesBannerState(knownPinnedMessagesCount = 2, currentPinnedMessageIndex = 0),
aLoadedPinnedMessagesBannerState(knownPinnedMessagesCount = 3, currentPinnedMessageIndex = 0),
aLoadedPinnedMessagesBannerState(knownPinnedMessagesCount = 4, currentPinnedMessageIndex = 0),
aLoadedPinnedMessagesBannerState(knownPinnedMessagesCount = 4, currentPinnedMessageIndex = 1),
aLoadedPinnedMessagesBannerState(knownPinnedMessagesCount = 4, currentPinnedMessageIndex = 2),
aLoadedPinnedMessagesBannerState(knownPinnedMessagesCount = 4, currentPinnedMessageIndex = 3),
)
}
internal fun aPinnedMessagesBannerState(
pinnedMessagesCount: Int = 0,
currentPinnedMessageIndex: Int = -1,
internal fun aHiddenPinnedMessagesBannerState() = PinnedMessagesBannerState.Hidden
internal fun aLoadingPinnedMessagesBannerState(
knownPinnedMessagesCount: Int = 4
) = PinnedMessagesBannerState.Loading(
realPinnedMessagesCount = knownPinnedMessagesCount
)
internal fun aLoadedPinnedMessagesBannerState(
currentPinnedMessageIndex: Int = 0,
knownPinnedMessagesCount: 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,
) = PinnedMessagesBannerState.Loaded(
currentPinnedMessage = currentPinnedMessage,
currentPinnedMessageIndex = currentPinnedMessageIndex,
knownPinnedMessagesCount = knownPinnedMessagesCount,
eventSink = eventSink
)

View File

@@ -71,42 +71,52 @@ fun PinnedMessagesBannerView(
onViewAllClick: () -> Unit,
modifier: Modifier = Modifier,
) {
if (state.currentPinnedMessage == null) return
Box(modifier = modifier) {
when (state) {
PinnedMessagesBannerState.Hidden -> Unit
is PinnedMessagesBannerState.Loading -> {
PinnedMessagesBannerRow(
state = state,
onViewAllClick = onViewAllClick,
modifier = Modifier.clickable(onClick = { }),
)
}
is PinnedMessagesBannerState.Loaded -> {
fun onClick() {
onClick(state.currentPinnedMessage.eventId)
state.eventSink(PinnedMessagesBannerEvents.MoveToNextPinned)
}
PinnedMessagesBannerRow(
state = state,
onViewAllClick = onViewAllClick,
modifier = Modifier.clickable(onClick = ::onClick),
)
}
}
}
}
@Composable
fun PinnedMessagesBannerRow(
state: PinnedMessagesBannerState,
onViewAllClick: () -> Unit,
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 {
onClick(state.currentPinnedMessage.eventId)
state.eventSink(PinnedMessagesBannerEvents.MoveToNextPinned)
},
.background(color = ElementTheme.colors.bgCanvasDefault)
.fillMaxWidth()
.drawBorder(borderColor)
.heightIn(min = 64.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = spacedBy(10.dp)
) {
Spacer(modifier = Modifier.width(16.dp))
PinIndicators(
pinIndex = state.currentPinnedMessageIndex,
pinsCount = state.pinnedMessagesCount,
pinIndex = state.currentPinnedMessageIndex(),
pinsCount = state.pinnedMessagesCount(),
modifier = Modifier.heightIn(max = 40.dp)
)
Icon(
@@ -116,15 +126,56 @@ fun PinnedMessagesBannerView(
modifier = Modifier.size(20.dp)
)
PinnedMessageItem(
index = state.currentPinnedMessageIndex,
totalCount = state.pinnedMessagesCount,
message = state.currentPinnedMessage.formatted,
index = state.currentPinnedMessageIndex(),
totalCount = state.pinnedMessagesCount(),
message = state.formattedMessage(),
modifier = Modifier.weight(1f)
)
TextButton(text = stringResource(id = CommonStrings.screen_room_pinned_banner_view_all_button_title), onClick = onViewAllClick)
ViewAllButton(state, onViewAllClick)
}
}
@Composable
private fun ViewAllButton(
state: PinnedMessagesBannerState,
onViewAllClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier) {
val text = if (state is PinnedMessagesBannerState.Loaded) {
stringResource(id = CommonStrings.screen_room_pinned_banner_view_all_button_title)
} else {
""
}
TextButton(
text = text,
showProgress = state is PinnedMessagesBannerState.Loading,
onClick = onViewAllClick
)
}
}
private fun Modifier.drawBorder(borderColor: Color): Modifier {
return this
.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)
}
@Composable
private fun PinIndicators(
pinIndex: Int,
@@ -157,15 +208,15 @@ private fun PinIndicators(
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
}
)
)
}
}
@@ -175,7 +226,7 @@ private fun PinIndicators(
private fun PinnedMessageItem(
index: Int,
totalCount: Int,
message: AnnotatedString,
message: AnnotatedString?,
modifier: Modifier = Modifier,
) {
val countMessage = stringResource(id = CommonStrings.screen_room_pinned_banner_indicator, index + 1, totalCount)
@@ -193,13 +244,15 @@ private fun PinnedMessageItem(
overflow = TextOverflow.Ellipsis,
)
}
Text(
text = message,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textPrimary,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
if (message != null) {
Text(
text = message,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textPrimary,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
}
}
}

View File

@@ -30,7 +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.pinned.banner.aLoadedPinnedMessagesBannerState
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
@@ -1072,7 +1072,7 @@ class MessagesPresenterTest {
customReactionPresenter = customReactionPresenter,
reactionSummaryPresenter = reactionSummaryPresenter,
readReceiptBottomSheetPresenter = readReceiptBottomSheetPresenter,
pinnedMessagesBannerPresenter = { aPinnedMessagesBannerState() },
pinnedMessagesBannerPresenter = { aLoadedPinnedMessagesBannerState() },
networkMonitor = FakeNetworkMonitor(),
snackbarDispatcher = SnackbarDispatcher(),
navigator = navigator,

View File

@@ -293,6 +293,7 @@ Reason: %1$s."</string>
<string name="screen_room_member_details_unblock_user">"Unblock user"</string>
<string name="screen_room_pinned_banner_indicator">"%1$s of %2$s"</string>
<string name="screen_room_pinned_banner_indicator_description">"%1$s Pinned messages"</string>
<string name="screen_room_pinned_banner_loading_description">"Loading message…"</string>
<string name="screen_room_pinned_banner_view_all_button_title">"View All"</string>
<string name="screen_room_title">"Chat"</string>
<string name="screen_share_location_title">"Share location"</string>