Merge pull request #3275 from element-hq/feature/fga/pinned_message_banner_logic
[Feature] Pinned message : banner logic
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -33,11 +33,12 @@ internal fun MessagesViewWithTypingPreview(
|
||||
onBackClick = {},
|
||||
onRoomDetailsClick = {},
|
||||
onEventClick = { false },
|
||||
onPreviewAttachments = {},
|
||||
onUserDataClick = {},
|
||||
onLinkClick = {},
|
||||
onPreviewAttachments = {},
|
||||
onSendLocationClick = {},
|
||||
onCreatePollClick = {},
|
||||
onJoinCallClick = {},
|
||||
onViewAllPinnedMessagesClick = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user