Merge pull request #3275 from element-hq/feature/fga/pinned_message_banner_logic

[Feature] Pinned message : banner logic
This commit is contained in:
ganfra
2024-08-08 15:15:15 +02:00
committed by GitHub
83 changed files with 1175 additions and 240 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

@@ -81,6 +81,7 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.parcelize.Parcelize
import timber.log.Timber
@ContributesNode(RoomScope::class)
class MessagesFlowNode @AssistedInject constructor(
@@ -217,6 +218,10 @@ class MessagesFlowNode @AssistedInject constructor(
analyticsService.captureInteraction(Interaction.Name.MobileRoomCallButton)
elementCallEntryPoint.startCall(callType)
}
override fun onViewAllPinnedEvents() {
Timber.d("On View All Pinned Events not implemented yet.")
}
}
val inputs = MessagesNode.Inputs(
focusedEventId = inputs.focusedEventId,

View File

@@ -97,6 +97,7 @@ class MessagesNode @AssistedInject constructor(
fun onCreatePollClick()
fun onEditPollClick(eventId: EventId)
fun onJoinCallClick(roomId: RoomId)
fun onViewAllPinnedEvents()
}
override fun onBuilt() {
@@ -185,6 +186,10 @@ class MessagesNode @AssistedInject constructor(
callbacks.forEach { it.onEditPollClick(eventId) }
}
private fun onViewAllPinnedMessagesClick() {
callbacks.forEach { it.onViewAllPinnedEvents() }
}
private fun onSendLocationClick() {
callbacks.forEach { it.onSendLocationClick() }
}
@@ -221,6 +226,7 @@ class MessagesNode @AssistedInject constructor(
onSendLocationClick = this::onSendLocationClick,
onCreatePollClick = this::onCreatePollClick,
onJoinCallClick = this::onJoinCallClick,
onViewAllPinnedMessagesClick = this::onViewAllPinnedMessagesClick,
modifier = modifier,
)

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

@@ -36,7 +36,6 @@ 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
@@ -72,7 +71,11 @@ 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.FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS
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
@@ -105,14 +108,15 @@ 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.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import timber.log.Timber
import kotlin.random.Random
import kotlin.time.Duration.Companion.milliseconds
@Composable
fun MessagesView(
@@ -126,8 +130,9 @@ fun MessagesView(
onSendLocationClick: () -> Unit,
onCreatePollClick: () -> Unit,
onJoinCallClick: () -> Unit,
onViewAllPinnedMessagesClick: () -> Unit,
modifier: Modifier = Modifier,
forceJumpToBottomVisibility: Boolean = false
forceJumpToBottomVisibility: Boolean = false,
) {
OnLifecycleEvent { _, event ->
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.LifecycleEvent(event))
@@ -228,6 +233,7 @@ fun MessagesView(
},
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
onJoinCallClick = onJoinCallClick,
onViewAllPinnedMessagesClick = onViewAllPinnedMessagesClick,
)
},
snackbarHost = {
@@ -319,6 +325,7 @@ private fun MessagesViewContent(
onSendLocationClick: () -> Unit,
onCreatePollClick: () -> Unit,
onJoinCallClick: () -> Unit,
onViewAllPinnedMessagesClick: () -> Unit,
forceJumpToBottomVisibility: Boolean,
modifier: Modifier = Modifier,
onSwipeToReply: (TimelineItem.Event) -> Unit,
@@ -377,7 +384,7 @@ private fun MessagesViewContent(
},
content = { paddingValues ->
Box(modifier = Modifier.padding(paddingValues)) {
val timelineLazyListState = rememberLazyListState()
val scrollBehavior = PinnedMessagesBannerViewDefaults.rememberExitOnScrollBehavior()
TimelineView(
state = state.timelineState,
typingNotificationState = state.typingNotificationState,
@@ -392,15 +399,22 @@ private fun MessagesViewContent(
onReadReceiptClick = onReadReceiptClick,
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
onJoinCallClick = onJoinCallClick,
lazyListState = timelineLazyListState,
nestedScrollConnection = scrollBehavior.nestedScrollConnection,
)
AnimatedVisibility(
visible = state.pinnedMessagesBannerState.displayBanner && timelineLazyListState.isScrollingUp(),
visible = state.pinnedMessagesBannerState is PinnedMessagesBannerState.Visible && scrollBehavior.isVisible,
enter = expandVertically(),
exit = shrinkVertically(),
) {
fun focusOnPinnedEvent(eventId: EventId) {
state.timelineState.eventSink(
TimelineEvents.FocusOnEvent(eventId = eventId, debounce = FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS.milliseconds)
)
}
PinnedMessagesBannerView(
state = state.pinnedMessagesBannerState,
onClick = ::focusOnPinnedEvent,
onViewAllClick = onViewAllPinnedMessagesClick,
)
}
}
@@ -572,12 +586,13 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class)
onBackClick = {},
onRoomDetailsClick = {},
onEventClick = { false },
onPreviewAttachments = {},
onUserDataClick = {},
onLinkClick = {},
onPreviewAttachments = {},
onSendLocationClick = {},
onCreatePollClick = {},
onJoinCallClick = {},
onViewAllPinnedMessagesClick = { },
forceJumpToBottomVisibility = true,
)
}

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,54 @@
/*
* 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
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
fun interface IsPinnedMessagesFeatureEnabled {
@Composable
operator fun invoke(): Boolean
}
@ContributesBinding(AppScope::class)
class DefaultIsPinnedMessagesFeatureEnabled @Inject constructor(
private val featureFlagService: FeatureFlagService,
) : IsPinnedMessagesFeatureEnabled {
@Composable
override operator fun invoke(): Boolean {
var isFeatureEnabled by rememberSaveable {
mutableStateOf(false)
}
LaunchedEffect(Unit) {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.PinnedEvents)
.onEach { isFeatureEnabled = it }
.launchIn(this)
}
return isFeatureEnabled
}
}

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

@@ -0,0 +1,47 @@
/*
* 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.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.eventformatter.api.PinnedMessagesBannerFormatter
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import kotlinx.coroutines.withContext
import javax.inject.Inject
class PinnedMessagesBannerItemFactory @Inject constructor(
private val coroutineDispatchers: CoroutineDispatchers,
private val formatter: PinnedMessagesBannerFormatter,
) {
suspend fun create(timelineItem: MatrixTimelineItem): PinnedMessagesBannerItem? = withContext(coroutineDispatchers.computation) {
when (timelineItem) {
is MatrixTimelineItem.Event -> {
val eventId = timelineItem.eventId ?: return@withContext null
val formatted = formatter.format(timelineItem.event)
PinnedMessagesBannerItem(
eventId = eventId,
formatted = if (formatted is AnnotatedString) {
formatted
} else {
AnnotatedString(formatted.toString())
},
)
}
else -> null
}
}
}

View File

@@ -17,40 +17,141 @@
package io.element.android.features.messages.impl.pinned.banner
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
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.messages.impl.pinned.IsPinnedMessagesFeatureEnabled
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.libraries.architecture.Presenter
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
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(
private val room: MatrixRoom,
private val itemFactory: PinnedMessagesBannerItemFactory,
private val isFeatureEnabled: IsPinnedMessagesFeatureEnabled,
private val networkMonitor: NetworkMonitor,
) : Presenter<PinnedMessagesBannerState> {
private val pinnedItems = mutableStateOf<ImmutableList<PinnedMessagesBannerItem>>(persistentListOf())
class PinnedMessagesBannerPresenter @Inject constructor() : Presenter<PinnedMessagesBannerState> {
@Composable
override fun present(): PinnedMessagesBannerState {
var pinnedMessageCount by remember {
mutableIntStateOf(0)
}
var currentPinnedMessageIndex by rememberSaveable {
mutableIntStateOf(0)
}
val isFeatureEnabled = isFeatureEnabled()
val expectedPinnedMessagesCount by remember {
room.roomInfoFlow.map { roomInfo -> roomInfo.pinnedEventIds.size }
}.collectAsState(initial = 0)
var hasTimelineFailedToLoad by rememberSaveable { mutableStateOf(false) }
var currentPinnedMessageIndex by rememberSaveable { mutableIntStateOf(-1) }
PinnedMessagesBannerItemsEffect(
isFeatureEnabled = isFeatureEnabled,
onItemsChange = { newItems ->
val pinnedMessageCount = newItems.size
if (currentPinnedMessageIndex >= pinnedMessageCount || currentPinnedMessageIndex < 0) {
currentPinnedMessageIndex = pinnedMessageCount - 1
}
pinnedItems.value = newItems
},
onTimelineFail = { hasTimelineFailed ->
hasTimelineFailedToLoad = hasTimelineFailed
}
)
fun handleEvent(event: PinnedMessagesBannerEvents) {
when (event) {
is PinnedMessagesBannerEvents.MoveToNextPinned -> {
if (currentPinnedMessageIndex < pinnedMessageCount - 1) {
currentPinnedMessageIndex++
} else {
currentPinnedMessageIndex = 0
}
currentPinnedMessageIndex = (currentPinnedMessageIndex - 1).mod(pinnedItems.value.size)
}
}
}
return PinnedMessagesBannerState(
pinnedMessagesCount = pinnedMessageCount,
return pinnedMessagesBannerState(
isFeatureEnabled = isFeatureEnabled,
hasTimelineFailed = hasTimelineFailedToLoad,
expectedPinnedMessagesCount = expectedPinnedMessagesCount,
pinnedItems = pinnedItems.value,
currentPinnedMessageIndex = currentPinnedMessageIndex,
eventSink = ::handleEvent
)
}
@Composable
private fun pinnedMessagesBannerState(
isFeatureEnabled: Boolean,
hasTimelineFailed: Boolean,
expectedPinnedMessagesCount: Int,
pinnedItems: ImmutableList<PinnedMessagesBannerItem>,
currentPinnedMessageIndex: Int,
eventSink: (PinnedMessagesBannerEvents) -> Unit
): PinnedMessagesBannerState {
val currentPinnedMessage = pinnedItems.getOrNull(currentPinnedMessageIndex)
return when {
!isFeatureEnabled -> PinnedMessagesBannerState.Hidden
hasTimelineFailed -> PinnedMessagesBannerState.Hidden
currentPinnedMessage != null -> PinnedMessagesBannerState.Loaded(
currentPinnedMessage = currentPinnedMessage,
currentPinnedMessageIndex = currentPinnedMessageIndex,
loadedPinnedMessagesCount = pinnedItems.size,
eventSink = eventSink
)
expectedPinnedMessagesCount == 0 -> PinnedMessagesBannerState.Hidden
else -> PinnedMessagesBannerState.Loading(expectedPinnedMessagesCount = expectedPinnedMessagesCount)
}
}
@OptIn(FlowPreview::class)
@Composable
private fun PinnedMessagesBannerItemsEffect(
isFeatureEnabled: Boolean,
onItemsChange: (ImmutableList<PinnedMessagesBannerItem>) -> Unit,
onTimelineFail: (Boolean) -> Unit,
) {
val updatedOnItemsChange by rememberUpdatedState(onItemsChange)
val updatedOnTimelineFail by rememberUpdatedState(onTimelineFail)
val networkStatus by networkMonitor.connectivity.collectAsState()
LaunchedEffect(isFeatureEnabled, networkStatus) {
if (!isFeatureEnabled) {
updatedOnItemsChange(persistentListOf())
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)
}
.onCompletion {
pinnedEventsTimeline.close()
}
.launchIn(this)
}
}
}

View File

@@ -16,10 +16,41 @@
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
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
sealed interface Visible : PinnedMessagesBannerState
data class Loading(val expectedPinnedMessagesCount: Int) : Visible
data class Loaded(
val currentPinnedMessage: PinnedMessagesBannerItem,
val currentPinnedMessageIndex: Int,
val loadedPinnedMessagesCount: Int,
val eventSink: (PinnedMessagesBannerEvents) -> Unit
) : Visible
fun pinnedMessagesCount() = when (this) {
is Hidden -> 0
is Loading -> expectedPinnedMessagesCount
is Loaded -> loadedPinnedMessagesCount
}
fun currentPinnedMessageIndex() = when (this) {
is Hidden -> 0
is Loading -> expectedPinnedMessagesCount - 1
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

@@ -16,26 +16,46 @@
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>
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(
expectedPinnedMessagesCount = 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,
) = PinnedMessagesBannerState.Loaded(
currentPinnedMessage = currentPinnedMessage,
currentPinnedMessageIndex = currentPinnedMessageIndex,
loadedPinnedMessagesCount = knownPinnedMessagesCount,
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,16 +33,21 @@ 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.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
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,46 +61,57 @@ 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,
onViewAllClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier) {
when (state) {
PinnedMessagesBannerState.Hidden -> Unit
is PinnedMessagesBannerState.Visible -> {
PinnedMessagesBannerRow(
state = state,
onClick = onClick,
onViewAllClick = onViewAllClick,
)
}
}
}
}
@Composable
private fun PinnedMessagesBannerRow(
state: PinnedMessagesBannerState,
onClick: (EventId) -> Unit,
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 {
.background(color = ElementTheme.colors.bgCanvasDefault)
.fillMaxWidth()
.drawBorder(borderColor)
.heightIn(min = 64.dp)
.clickable {
if (state is PinnedMessagesBannerState.Loaded) {
onClick(state.currentPinnedMessage.eventId)
state.eventSink(PinnedMessagesBannerEvents.MoveToNextPinned)
},
}
},
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(
@@ -104,29 +121,68 @@ fun PinnedMessagesBannerView(
modifier = Modifier.size(20.dp)
)
PinnedMessageItem(
index = state.currentPinnedMessageIndex,
totalCount = state.pinnedMessagesCount,
message = "This is a pinned message",
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 = { /*TODO*/ })
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,
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 +197,20 @@ private fun PinIndicators(
modifier = modifier,
state = lazyListState,
verticalArrangement = spacedBy(2.dp),
userScrollEnabled = false
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
}
)
.width(2.dp)
.height(indicatorHeight.dp)
.background(
color = if (index == pinIndex) {
ElementTheme.colors.iconAccentPrimary
} else {
ElementTheme.colors.pinnedMessageBannerIndicator
}
)
)
}
}
@@ -164,13 +220,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,15 +235,46 @@ private fun PinnedMessageItem(
style = ElementTheme.typography.fontBodySmMedium,
color = ElementTheme.colors.textActionAccent,
maxLines = 1,
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,
)
}
}
}
@Stable
internal interface PinnedMessagesBannerViewScrollBehavior {
val isVisible: Boolean
val nestedScrollConnection: NestedScrollConnection
}
internal object PinnedMessagesBannerViewDefaults {
@Composable
fun rememberExitOnScrollBehavior(): PinnedMessagesBannerViewScrollBehavior = remember {
ExitOnScrollBehavior()
}
}
private class ExitOnScrollBehavior : PinnedMessagesBannerViewScrollBehavior {
override var isVisible by mutableStateOf(true)
override val nestedScrollConnection: NestedScrollConnection = object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
if (available.y < -1) {
isVisible = true
}
if (available.y > 1) {
isVisible = false
}
return Offset.Zero
}
}
}
@@ -196,5 +283,7 @@ private fun PinnedMessageItem(
internal fun PinnedMessagesBannerViewPreview(@PreviewParameter(PinnedMessagesBannerStateProvider::class) state: PinnedMessagesBannerState) = ElementPreview {
PinnedMessagesBannerView(
state = state,
onClick = {},
onViewAllClick = {},
)
}

View File

@@ -18,10 +18,11 @@ package io.element.android.features.messages.impl.timeline
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.Timeline
import kotlin.time.Duration
sealed interface TimelineEvents {
data class OnScrollFinished(val firstIndex: Int) : TimelineEvents
data class FocusOnEvent(val eventId: EventId) : TimelineEvents
data class FocusOnEvent(val eventId: EventId, val debounce: Duration = Duration.ZERO) : TimelineEvents
data object ClearFocusRequestState : TimelineEvents
data object OnFocusEventRender : TimelineEvents
data object JumpToLive : TimelineEvents

View File

@@ -50,12 +50,15 @@ import io.element.android.libraries.matrix.ui.room.canSendMessageAsState
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
const val FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS = 200L
class TimelinePresenter @AssistedInject constructor(
private val timelineItemsFactory: TimelineItemsFactory,
private val timelineItemIndexer: TimelineItemIndexer,
@@ -136,13 +139,8 @@ class TimelinePresenter @AssistedInject constructor(
is TimelineEvents.EditPoll -> {
navigator.onEditPollClick(event.pollStartId)
}
is TimelineEvents.FocusOnEvent -> localScope.launch {
if (timelineItemIndexer.isKnown(event.eventId)) {
val index = timelineItemIndexer.indexOf(event.eventId)
focusRequestState.value = FocusRequestState.Success(eventId = event.eventId, index = index)
} else {
focusRequestState.value = FocusRequestState.Loading(eventId = event.eventId)
}
is TimelineEvents.FocusOnEvent -> {
focusRequestState.value = FocusRequestState.Requested(event.eventId, event.debounce)
}
is TimelineEvents.OnFocusEventRender -> {
focusRequestState.value = focusRequestState.value.onFocusEventRender()
@@ -157,18 +155,29 @@ class TimelinePresenter @AssistedInject constructor(
}
LaunchedEffect(focusRequestState.value) {
val currentFocusRequestState = focusRequestState.value
if (currentFocusRequestState is FocusRequestState.Loading) {
val eventId = currentFocusRequestState.eventId
timelineController.focusOnEvent(eventId)
.fold(
onSuccess = {
focusRequestState.value = FocusRequestState.Success(eventId = eventId)
},
onFailure = {
focusRequestState.value = FocusRequestState.Failure(throwable = it)
}
)
when (val currentFocusRequestState = focusRequestState.value) {
is FocusRequestState.Requested -> {
delay(currentFocusRequestState.debounce)
if (timelineItemIndexer.isKnown(currentFocusRequestState.eventId)) {
val index = timelineItemIndexer.indexOf(currentFocusRequestState.eventId)
focusRequestState.value = FocusRequestState.Success(eventId = currentFocusRequestState.eventId, index = index)
} else {
focusRequestState.value = FocusRequestState.Loading(eventId = currentFocusRequestState.eventId)
}
}
is FocusRequestState.Loading -> {
val eventId = currentFocusRequestState.eventId
timelineController.focusOnEvent(eventId)
.fold(
onSuccess = {
focusRequestState.value = FocusRequestState.Success(eventId = eventId)
},
onFailure = {
focusRequestState.value = FocusRequestState.Failure(throwable = it)
}
)
}
else -> Unit
}
}

View File

@@ -21,6 +21,7 @@ import io.element.android.features.messages.impl.timeline.model.NewEventState
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.matrix.api.core.EventId
import kotlinx.collections.immutable.ImmutableList
import kotlin.time.Duration
@Immutable
data class TimelineState(
@@ -39,6 +40,7 @@ data class TimelineState(
@Immutable
sealed interface FocusRequestState {
data object None : FocusRequestState
data class Requested(val eventId: EventId, val debounce: Duration) : FocusRequestState
data class Loading(val eventId: EventId) : FocusRequestState
data class Success(
val eventId: EventId,
@@ -54,6 +56,7 @@ sealed interface FocusRequestState {
fun eventId(): EventId? {
return when (this) {
is Requested -> eventId
is Loading -> eventId
is Success -> eventId
else -> null

View File

@@ -48,7 +48,10 @@ import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
@@ -91,7 +94,8 @@ fun TimelineView(
onJoinCallClick: () -> Unit,
modifier: Modifier = Modifier,
lazyListState: LazyListState = rememberLazyListState(),
forceJumpToBottomVisibility: Boolean = false
forceJumpToBottomVisibility: Boolean = false,
nestedScrollConnection: NestedScrollConnection = rememberNestedScrollInteropConnection(),
) {
fun clearFocusRequestState() {
state.eventSink(TimelineEvents.ClearFocusRequestState)
@@ -124,7 +128,9 @@ fun TimelineView(
AnimatedVisibility(visible = true, enter = fadeIn()) {
Box(modifier) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
modifier = Modifier
.fillMaxSize()
.nestedScroll(nestedScrollConnection),
state = lazyListState,
reverseLayout = useReverseLayout,
contentPadding = PaddingValues(vertical = 8.dp),

View File

@@ -33,11 +33,12 @@ internal fun MessagesViewWithTypingPreview(
onBackClick = {},
onRoomDetailsClick = {},
onEventClick = { false },
onPreviewAttachments = {},
onUserDataClick = {},
onLinkClick = {},
onPreviewAttachments = {},
onSendLocationClick = {},
onCreatePollClick = {},
onJoinCallClick = {},
onViewAllPinnedMessagesClick = {},
)
}

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

@@ -33,6 +33,7 @@ import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeRight
import androidx.compose.ui.text.AnnotatedString
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.emojibasebindings.Emoji
import io.element.android.emojibasebindings.EmojibaseCategory
@@ -43,6 +44,10 @@ import io.element.android.features.messages.impl.actionlist.anActionListState
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerItem
import io.element.android.features.messages.impl.pinned.banner.aLoadedPinnedMessagesBannerState
import io.element.android.features.messages.impl.timeline.FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemReadReceipts
import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo
@@ -54,6 +59,7 @@ import io.element.android.features.messages.impl.timeline.components.receipt.aRe
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvents
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureCalledOnceWithParam
@@ -72,6 +78,7 @@ import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import kotlin.time.Duration.Companion.milliseconds
@RunWith(AndroidJUnit4::class)
class MessagesViewTest {
@@ -458,6 +465,25 @@ class MessagesViewTest {
customReactionStateEventsRecorder.assertSingle(CustomReactionEvents.DismissCustomReactionSheet)
eventsRecorder.assertSingle(MessagesEvents.ToggleReaction(aUnicode, timelineItem.eventId!!))
}
@Test
fun `clicking on pinned messages banner emits the expected Event`() {
val eventsRecorder = EventsRecorder<TimelineEvents>()
val state = aMessagesState(
timelineState = aTimelineState(eventSink = eventsRecorder),
pinnedMessagesBannerState = aLoadedPinnedMessagesBannerState(
knownPinnedMessagesCount = 2,
currentPinnedMessageIndex = 0,
currentPinnedMessage = PinnedMessagesBannerItem(
eventId = AN_EVENT_ID,
formatted = AnnotatedString("This is a pinned message")
),
),
)
rule.setMessagesView(state = state)
rule.onNodeWithText("This is a pinned message").performClick()
eventsRecorder.assertSingle(TimelineEvents.FocusOnEvent(AN_EVENT_ID, debounce = FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS.milliseconds))
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessagesView(
@@ -471,6 +497,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessa
onSendLocationClick: () -> Unit = EnsureNeverCalled(),
onCreatePollClick: () -> Unit = EnsureNeverCalled(),
onJoinCallClick: () -> Unit = EnsureNeverCalled(),
onViewAllPinnedMessagesClick: () -> Unit = EnsureNeverCalled(),
) {
setContent {
// Cannot use the RichTextEditor, so simulate a LocalInspectionMode
@@ -488,6 +515,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessa
onSendLocationClick = onSendLocationClick,
onCreatePollClick = onCreatePollClick,
onJoinCallClick = onJoinCallClick,
onViewAllPinnedMessagesClick = onViewAllPinnedMessagesClick,
)
}
}

View File

@@ -17,33 +17,187 @@
package io.element.android.features.messages.impl.pinned.banner
import com.google.common.truth.Truth.assertThat
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.eventformatter.test.FakePinnedMessagesBannerFormatter
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.matrix.test.timeline.aMessageContent
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
class PinnedMessagesBannerPresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = createPinnedMessagesBannerPresenter()
val presenter = createPinnedMessagesBannerPresenter(isFeatureEnabled = true)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.pinnedMessagesCount).isEqualTo(0)
assertThat(initialState.currentPinnedMessageIndex).isEqualTo(0)
assertThat(initialState).isEqualTo(PinnedMessagesBannerState.Hidden)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - move to next pinned message when there is no pinned events`() = runTest {
val presenter = createPinnedMessagesBannerPresenter()
fun `present - feature disabled`() = runTest {
val presenter = createPinnedMessagesBannerPresenter(isFeatureEnabled = false)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(PinnedMessagesBannerEvents.MoveToNextPinned)
// Nothing is emitted
ensureAllEventsConsumed()
assertThat(initialState).isEqualTo(PinnedMessagesBannerState.Hidden)
}
}
private fun createPinnedMessagesBannerPresenter(): PinnedMessagesBannerPresenter {
return PinnedMessagesBannerPresenter()
@Test
fun `present - loading state`() = runTest {
val room = FakeMatrixRoom(
pinnedEventsTimelineResult = { Result.success(FakeTimeline()) }
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
}
val presenter = createPinnedMessagesBannerPresenter(room = room)
presenter.test {
skipItems(1)
val loadingState = awaitItem()
assertThat(loadingState).isEqualTo(PinnedMessagesBannerState.Loading(1))
assertThat(loadingState.pinnedMessagesCount()).isEqualTo(1)
assertThat(loadingState.currentPinnedMessageIndex()).isEqualTo(0)
}
}
@Test
fun `present - loaded state`() = runTest {
val messageContent = aMessageContent("A message")
val pinnedEventsTimeline = FakeTimeline(
timelineItems = flowOf(
listOf(
MatrixTimelineItem.Event(
uniqueId = "FAKE_UNIQUE_ID",
event = anEventTimelineItem(
eventId = AN_EVENT_ID,
content = messageContent,
),
)
)
)
)
val room = FakeMatrixRoom(
pinnedEventsTimelineResult = { Result.success(pinnedEventsTimeline) }
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID, AN_EVENT_ID_2)))
}
val presenter = createPinnedMessagesBannerPresenter(room = room)
presenter.test {
skipItems(2)
val loadedState = awaitItem() as PinnedMessagesBannerState.Loaded
assertThat(loadedState.currentPinnedMessageIndex).isEqualTo(0)
assertThat(loadedState.loadedPinnedMessagesCount).isEqualTo(1)
assertThat(loadedState.currentPinnedMessage.formatted.text).isEqualTo(messageContent.toString())
}
}
@Test
fun `present - loaded state - multiple pinned messages`() = runTest {
val messageContent1 = aMessageContent("A message")
val messageContent2 = aMessageContent("Another message")
val pinnedEventsTimeline = FakeTimeline(
timelineItems = flowOf(
listOf(
MatrixTimelineItem.Event(
uniqueId = "FAKE_UNIQUE_ID",
event = anEventTimelineItem(
eventId = AN_EVENT_ID,
content = messageContent1,
),
),
MatrixTimelineItem.Event(
uniqueId = "FAKE_UNIQUE_ID_2",
event = anEventTimelineItem(
eventId = AN_EVENT_ID_2,
content = messageContent2,
),
)
)
)
)
val room = FakeMatrixRoom(
pinnedEventsTimelineResult = { Result.success(pinnedEventsTimeline) }
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID, AN_EVENT_ID_2)))
}
val presenter = createPinnedMessagesBannerPresenter(room = room)
presenter.test {
skipItems(2)
awaitItem().also { loadedState ->
loadedState as PinnedMessagesBannerState.Loaded
assertThat(loadedState.currentPinnedMessageIndex).isEqualTo(1)
assertThat(loadedState.loadedPinnedMessagesCount).isEqualTo(2)
assertThat(loadedState.currentPinnedMessage.formatted.text).isEqualTo(messageContent2.toString())
loadedState.eventSink(PinnedMessagesBannerEvents.MoveToNextPinned)
}
awaitItem().also { loadedState ->
loadedState as PinnedMessagesBannerState.Loaded
assertThat(loadedState.currentPinnedMessageIndex).isEqualTo(0)
assertThat(loadedState.loadedPinnedMessagesCount).isEqualTo(2)
assertThat(loadedState.currentPinnedMessage.formatted.text).isEqualTo(messageContent1.toString())
loadedState.eventSink(PinnedMessagesBannerEvents.MoveToNextPinned)
}
awaitItem().also { loadedState ->
loadedState as PinnedMessagesBannerState.Loaded
assertThat(loadedState.currentPinnedMessageIndex).isEqualTo(1)
assertThat(loadedState.loadedPinnedMessagesCount).isEqualTo(2)
assertThat(loadedState.currentPinnedMessage.formatted.text).isEqualTo(messageContent2.toString())
}
}
}
@Test
fun `present - timeline failed`() = runTest {
val room = FakeMatrixRoom(
pinnedEventsTimelineResult = { Result.failure(Exception()) }
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
}
val presenter = createPinnedMessagesBannerPresenter(room = room)
presenter.test {
skipItems(1)
awaitItem().also { loadingState ->
assertThat(loadingState).isEqualTo(PinnedMessagesBannerState.Loading(1))
assertThat(loadingState.pinnedMessagesCount()).isEqualTo(1)
assertThat(loadingState.currentPinnedMessageIndex()).isEqualTo(0)
}
awaitItem().also { failedState ->
assertThat(failedState).isEqualTo(PinnedMessagesBannerState.Hidden)
}
}
}
private fun TestScope.createPinnedMessagesBannerPresenter(
room: MatrixRoom = FakeMatrixRoom(),
itemFactory: PinnedMessagesBannerItemFactory = PinnedMessagesBannerItemFactory(
coroutineDispatchers = testCoroutineDispatchers(),
formatter = FakePinnedMessagesBannerFormatter(
formatLambda = { event -> "${event.content}" }
)
),
networkMonitor: NetworkMonitor = FakeNetworkMonitor(),
isFeatureEnabled: Boolean = true,
): PinnedMessagesBannerPresenter {
return PinnedMessagesBannerPresenter(
room = room,
itemFactory = itemFactory,
isFeatureEnabled = { isFeatureEnabled },
networkMonitor = networkMonitor,
)
}
}

View File

@@ -0,0 +1,87 @@
/*
* 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.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class PinnedMessagesBannerViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking on the banner invoke expected callback`() {
val eventsRecorder = EventsRecorder<PinnedMessagesBannerEvents>()
val state = aLoadedPinnedMessagesBannerState(
eventSink = eventsRecorder
)
val pinnedEventId = state.currentPinnedMessage.eventId
ensureCalledOnceWithParam(pinnedEventId) { callback ->
rule.setPinnedMessagesBannerView(
state = state,
onClick = callback
)
rule.onRoot().performClick()
eventsRecorder.assertSingle(PinnedMessagesBannerEvents.MoveToNextPinned)
}
}
@Test
fun `clicking on view all emit the expected event`() {
val eventsRecorder = EventsRecorder<PinnedMessagesBannerEvents>(expectEvents = true)
val state = aLoadedPinnedMessagesBannerState(
eventSink = eventsRecorder
)
ensureCalledOnce { callback ->
rule.setPinnedMessagesBannerView(
state = state,
onViewAllClick = callback
)
rule.clickOn(CommonStrings.screen_room_pinned_banner_view_all_button_title)
}
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setPinnedMessagesBannerView(
state: PinnedMessagesBannerState,
onClick: (EventId) -> Unit = EnsureNeverCalledWithParam(),
onViewAllClick: () -> Unit = EnsureNeverCalled(),
) {
setContent {
PinnedMessagesBannerView(
state = state,
onClick = onClick,
onViewAllClick = onViewAllClick
)
}
}

View File

@@ -75,6 +75,7 @@ import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import java.util.Date
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
private const val FAKE_UNIQUE_ID = "FAKE_UNIQUE_ID"
@@ -496,6 +497,10 @@ private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
}.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
awaitItem().also { state ->
assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID)
assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Requested(AN_EVENT_ID, Duration.ZERO))
}
awaitItem().also { state ->
assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID)
assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Loading(AN_EVENT_ID))
@@ -541,6 +546,10 @@ private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
}.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
awaitItem().also { state ->
assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID)
assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Requested(AN_EVENT_ID, Duration.ZERO))
}
awaitItem().also { state ->
assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID)
assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Success(AN_EVENT_ID, 0))
@@ -564,6 +573,10 @@ private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
}.test {
val initialState = awaitFirstItem()
initialState.eventSink(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
awaitItem().also { state ->
assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID)
assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Requested(AN_EVENT_ID, Duration.ZERO))
}
awaitItem().also { state ->
assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID)
assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Loading(AN_EVENT_ID))

View File

@@ -44,7 +44,7 @@ serialization_json = "1.6.3"
showkase = "1.0.3"
appyx = "1.4.0"
sqldelight = "2.0.2"
wysiwyg = "2.37.8"
wysiwyg = "2.37.7"
telephoto = "0.12.1"
# DI
@@ -187,7 +187,7 @@ play_services_oss_licenses = "com.google.android.gms:play-services-oss-licenses:
# Analytics
posthog = "com.posthog:posthog-android:3.4.2"
sentry = "io.sentry:sentry-android:7.13.0"
sentry = "io.sentry:sentry-android:7.12.1"
# main branch can be tested replacing the version with main-SNAPSHOT
matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.23.1"

View File

@@ -0,0 +1,23 @@
/*
* 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.api
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
interface PinnedMessagesBannerFormatter {
fun format(event: EventTimelineItem): CharSequence
}

View File

@@ -0,0 +1,121 @@
/*
* 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.impl
import androidx.annotation.StringRes
import androidx.compose.ui.text.AnnotatedString
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.eventformatter.api.PinnedMessagesBannerFormatter
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageType
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent
import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import io.element.android.libraries.matrix.ui.messages.toPlainText
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.toolbox.api.strings.StringProvider
import javax.inject.Inject
@ContributesBinding(SessionScope::class)
class DefaultPinnedMessagesBannerFormatter @Inject constructor(
private val sp: StringProvider,
private val permalinkParser: PermalinkParser,
) : PinnedMessagesBannerFormatter {
override fun format(event: EventTimelineItem): CharSequence {
return when (val content = event.content) {
is MessageContent -> processMessageContents(event, content)
is StickerContent -> {
content.body.prefixWith(CommonStrings.common_sticker)
}
is UnableToDecryptContent -> {
sp.getString(CommonStrings.common_waiting_for_decryption_key)
}
is PollContent -> {
content.question.prefixWith(CommonStrings.a11y_poll)
}
RedactedContent -> {
sp.getString(CommonStrings.common_message_removed)
}
else -> {
sp.getString(CommonStrings.common_unsupported_event)
}
}
}
private fun processMessageContents(
event: EventTimelineItem,
messageContent: MessageContent,
): CharSequence {
return when (val messageType: MessageType = messageContent.type) {
is EmoteMessageType -> {
val senderDisambiguatedDisplayName = event.senderProfile.getDisambiguatedDisplayName(event.sender)
"* $senderDisambiguatedDisplayName ${messageType.body}"
}
is TextMessageType -> {
messageType.toPlainText(permalinkParser)
}
is VideoMessageType -> {
messageType.body.prefixWith(CommonStrings.common_video)
}
is ImageMessageType -> {
messageType.body.prefixWith(CommonStrings.common_image)
}
is StickerMessageType -> {
messageType.body.prefixWith(CommonStrings.common_sticker)
}
is LocationMessageType -> {
messageType.body.prefixWith(CommonStrings.common_shared_location)
}
is FileMessageType -> {
messageType.body.prefixWith(CommonStrings.common_file)
}
is AudioMessageType -> {
messageType.body.prefixWith(CommonStrings.common_audio)
}
is VoiceMessageType -> {
messageType.body.prefixWith(CommonStrings.common_voice_message)
}
is OtherMessageType -> {
messageType.body
}
is NoticeMessageType -> {
messageType.body
}
}
}
private fun CharSequence.prefixWith(@StringRes res: Int): AnnotatedString {
val prefix = sp.getString(res)
return prefixWith(prefix)
}
}

View File

@@ -16,11 +16,6 @@
package io.element.android.libraries.eventformatter.impl
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter
@@ -79,7 +74,7 @@ class DefaultRoomLastMessageFormatter @Inject constructor(
RedactedContent -> {
val message = sp.getString(CommonStrings.common_message_removed)
if (!isDmRoom) {
prefix(message, senderDisambiguatedDisplayName)
message.prefixWith(senderDisambiguatedDisplayName)
} else {
message
}
@@ -90,7 +85,7 @@ class DefaultRoomLastMessageFormatter @Inject constructor(
is UnableToDecryptContent -> {
val message = sp.getString(CommonStrings.common_waiting_for_decryption_key)
if (!isDmRoom) {
prefix(message, senderDisambiguatedDisplayName)
message.prefixWith(senderDisambiguatedDisplayName)
} else {
message
}
@@ -113,7 +108,6 @@ class DefaultRoomLastMessageFormatter @Inject constructor(
}
is LegacyCallInviteContent -> sp.getString(CommonStrings.common_call_invite)
is CallNotifyContent -> sp.getString(CommonStrings.common_call_started)
else -> null
}?.take(MAX_SAFE_LENGTH)
}
@@ -168,16 +162,6 @@ class DefaultRoomLastMessageFormatter @Inject constructor(
): CharSequence = if (isDmRoom) {
message
} else {
prefix(message, senderDisambiguatedDisplayName)
}
private fun prefix(message: String, senderDisambiguatedDisplayName: String): AnnotatedString {
return buildAnnotatedString {
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(senderDisambiguatedDisplayName)
}
append(": ")
append(message)
}
message.prefixWith(senderDisambiguatedDisplayName)
}
}

View File

@@ -0,0 +1,33 @@
/*
* 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.impl
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
internal fun CharSequence.prefixWith(prefix: String): AnnotatedString {
return buildAnnotatedString {
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(prefix)
}
append(": ")
append(this@prefixWith)
}
}

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)
}
}

View File

@@ -106,6 +106,11 @@ interface MatrixRoom : Closeable {
*/
suspend fun timelineFocusedOnEvent(eventId: EventId): Result<Timeline>
/**
* Create a new timeline for the pinned events of the room.
*/
suspend fun pinnedEventsTimeline(): Result<Timeline>
fun destroy()
suspend fun subscribeToSync()

View File

@@ -192,6 +192,21 @@ class RustMatrixRoom(
}
}
override suspend fun pinnedEventsTimeline(): Result<Timeline> {
return runCatching {
innerRoom.pinnedEventsTimeline(
internalIdPrefix = "pinned_events",
maxEventsToLoad = 100u,
).let { inner ->
createTimeline(inner, isLive = false)
}
}.onFailure {
if (it is CancellationException) {
throw it
}
}
}
override fun destroy() {
roomCoroutineScope.cancel()
liveTimeline.close()

View File

@@ -135,6 +135,7 @@ class FakeMatrixRoom(
private val updateMembersResult: () -> Unit = { lambdaError() },
private val getMembersResult: (Int) -> Result<List<RoomMember>> = { lambdaError() },
private val timelineFocusedOnEventResult: (EventId) -> Result<Timeline> = { lambdaError() },
private val pinnedEventsTimelineResult: () -> Result<Timeline> = { lambdaError() },
private val setSendQueueEnabledLambda: (Boolean) -> Unit = { _: Boolean -> },
private val saveComposerDraftLambda: (ComposerDraft) -> Result<Unit> = { _: ComposerDraft -> Result.success(Unit) },
private val loadComposerDraftLambda: () -> Result<ComposerDraft?> = { Result.success<ComposerDraft?>(null) },
@@ -182,6 +183,10 @@ class FakeMatrixRoom(
timelineFocusedOnEventResult(eventId)
}
override suspend fun pinnedEventsTimeline(): Result<Timeline> = simulateLongTask {
pinnedEventsTimelineResult()
}
override suspend fun subscribeToSync() {
subscribeToSyncLambda()
}

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>