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
commit 9cdec78126
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:40960e8da93791449132dcc4c77a0ee4ca711358ea8f184c080bb4dddbe01724
size 53669
oid sha256:5c96744ce341811ece23e037fe15bdeeb48c7c6d9a0e931fec1bbd9993679aed
size 56230

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e3505a9a29ca2636dbf201b1bdfb0a52d05ec885b5b14709dca3f3d1184cf258
size 54547
oid sha256:bb81645a873e815d96ec14efe0f84a2ff354093e62fb4957878bfc6ae214b9b8
size 57067

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c495a8d5d9d4cef2f258d39decc7c841b2bee5196a330944e745d78af42b046c
size 50595
oid sha256:4886e89da091d3869bfedb4e6f50009976fcab59f9617b8a4083c00e7027459a
size 53118

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:43e55acf964c3bde105c3b00eaa9d67d9cdeb3334f0d641d18c5c2476a178655
size 54416
oid sha256:bc69aaab31d3f7e63b0481818b0c91c90aef44afc21ee06591ba69cdf45b978a
size 56712

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1c749a0dff6fa96383268514cc330e02d13c1f50e5e6d238539c612cad7ba54d
size 55283
oid sha256:0123a32464a8ca8ca3f85af8bfde18aca054c63e91ec1264a54e0177c58f0d4b
size 57539

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d741537e51e539a627aaee9a567a52a5a96207c8dfb7d69ce799438179ad6c29
size 51388
oid sha256:d5b8ff0e7e06516a29694c46cd22c5f09f029725cc3556c92a4491c00868151f
size 53662

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ff311eba5365bb2a726eb59b71ecc40295032003dd5e03587efed086bdca79ec
size 55228
oid sha256:277463b454876b3bff870e27deafd2b1d7affcdb5b72efcbb33bc4bfeac0a961
size 56838

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a24bfa8d881fac3d302898f7721c202c4cdedb37d4a75277cdc551a3a4e6ab80
size 56882
oid sha256:50517ad2782539a02ad2ac302e15da20f70e48f7b3ceab9438f41f4cb590d35a
size 58414

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6bca01015488a95b5a7daf277fe2e8114012e26ddacad195ddffba6c30edb080
size 47104
oid sha256:d5f6843d5976f1b476f36a01e6db8cce729862a5d138f23b2053f4a2f085d6df
size 48527

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b2b13b08c823c492457daf9b54ae17616845154af22a58880cd1081ab38b304c
size 55264
oid sha256:3f2e8218de84709a8f42a9b4594c92da51a0427651de3c85885e8f147527bad9
size 56860

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8424c63cd12ce518c096d62c8aeeffc5d7f7308fed1c1612dca7ad8e832606c4
size 54790
oid sha256:26490a489101d7718b08a7a85982af6dc1458dd01a1b9491903278d66724d0b6
size 55998

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8292a8b339592e3caf7118a16584ca41754900f03a8fa89bbc2424d2c3a6c9f1
size 57701
oid sha256:b3d185905f72389bd4edbed4d2ec7b6f4547465004174a1c798e604e83f84363
size 59845

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:21f6c7ac3951f1aca51d6a2eabca7fdbdb1e00b3b3a3c44c96dae180bbee3623
size 53216
oid sha256:60cb1e0ab614fd719991e6c27ffdec3f68b6d9c6a7e0c5a8294eab3e4e51fa89
size 54618

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d6db28acda62e4343ba7b4f64f31070253fe968f6ed8464afb1bb54506353278
size 53081
oid sha256:28615c037c99b201dd987faa83b4d184b7c593d0f3871c8f731d681ee6795a1d
size 54688

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:806695481b7fe30e0c315b2e46c6fe48c21a1cedbfaf21736ad2716a41212473
size 53645
oid sha256:b6c85d42e90ea4c60d2cd4e0ac96fc38cb45504e1d863a4283f157f23e790899
size 54346

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c46f2842388c3d2d14cddae957ccc74c4a108faceff36846f5ca7daa00b9f593
size 56398
oid sha256:e5cbd166edeaec6ab06a5da87a0a023adfbb6f2e9727b4471c5586a2b897dbab
size 57729

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7e85f80a115c90586f81127b87aa5874a2f811d15c6af443bc0f0d7078c85f4e
size 38280
oid sha256:8170faa5461e703c09803966cb25be7da37cbad08d6c8e21584c75eccf8a7d29
size 39873

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a9ae1d25882c0c05723b68c61f97ac4ff2423750913f1f3186a6dad48e845e8e
size 37557
oid sha256:1362fddcca30b31f92575d3e8020b4dc1c415ed0afefe29c56d5f2179d6a35f1
size 39043

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1e1974d0ec89208a55b0faeb230c89fc3ed10c1d4bead234dc39c06169ab24ca
size 55060
oid sha256:16afa707859ef41a8420eec52f3e7e2e2c0d2dcf4b438e068468dcaa77fd4200
size 56547

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:74a2c962831b6468dbff878d6f55460a5cdbc3d06c7566cecfb89b7bcf049b18
size 56600
oid sha256:ce3e8d221e1a4c9557fabd5902dd507d3c9a6774c4185612b708ef80499e13d3
size 58015

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e468164817c0e6302c1f8cacecb24daed2fe65eb5453723186651dc7bc1851cf
size 43297
oid sha256:4dbd737eaa65c834550ab421e4e3ee5c77701ab58ce06197048dbf8e044c548a
size 44487

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ad336e1b96ac2528cd1d7573fda649e5fe09235b72d555d79f32cca4eb8c1c05
size 55084
oid sha256:0bdec5d25d991452633e77e4d5c2468d4b48cd880fbfb713a6c3b897cbdde921
size 56587

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d5b9fe533cfad8892708a609a79fa1f8bed95ad8fb20b979993da9b2d1ea380d
size 54511
oid sha256:872853bf96c32e0f375d6ec772437e27e21d16506f7fa2f7853ef91cd274bb28
size 55440

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a4d0b4ab8e2234e08ca7c67d4653729abcf2c4ed055b983fdde15589daaf0124
size 57234
oid sha256:86a3e684d4a794915344963c4dd26e1e68729e7e5d9c4091c7824cf0901684b3
size 59342

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a4a32531adceea6b03b25291435fb2df753868571b3edf3ea29f00353d07087e
size 49544
oid sha256:c446a2b24ae180496649f518f53f15f939c1e166093b82e479d2d7f1a8a7829d
size 50742

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:534490ea60a6078200761864dd112f54be124647f818ccec77fb986ea71582a8
size 52689
oid sha256:3df1d346ea653cfafea9e2e13a7ff47017a480ce593d1c959553e7b8073f0250
size 54217

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2063163a5ac3c78398df388b9e471362a32244d5bdf4790409358640b5cfb15f
size 53399
oid sha256:6dcf842a6bc3abfc9168b0e30354f3de2f72bddb04b203dcf9d211c77155bcfd
size 53901

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5d0d0b885ae157ff456d7113707bc72f3ced9ab74c895cdf7377adc1bc8c48db
size 52373
oid sha256:8639bc9d5728e8bf5b1b3ab76aff16aacf89ff15397770f08c5211dc9342fa78
size 53545

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:828b9893e06b168b16b42950f42b89ecc51b73c9b425732d379a72d737f04491
size 36126
oid sha256:310862ba0323270901b3a554ff230931a75de56a77487c7b0e63137f90cc0214
size 37398

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f251ca0e5c4f98b7c5889b42161e4d52f3faf37850c242122aac2d124a1357b1
size 35393
oid sha256:5abde960f15c7d28e6fc538f5450f277e89ee17893e12daaba004ca7a0f09974
size 36656