Pinned events : handle focus on pinned event

This commit is contained in:
ganfra 2024-08-02 15:35:53 +02:00
parent c820003414
commit 7d177b4b4d
11 changed files with 101 additions and 40 deletions

View file

@ -17,7 +17,6 @@
package io.element.android.features.messages.impl package io.element.android.features.messages.impl
import android.os.Parcelable import android.os.Parcelable
import android.widget.Toast
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier

View file

@ -36,7 +36,6 @@ import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -73,6 +72,8 @@ import io.element.android.features.messages.impl.messagecomposer.AttachmentsStat
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents 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.messagecomposer.MessageComposerView
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerView import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerView
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerViewDefaults
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelineView 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.JoinCallMenuItem
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet
@ -105,14 +106,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.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.KeepScreenOn import io.element.android.libraries.designsystem.utils.KeepScreenOn
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent 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.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState 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.matrix.api.core.UserId
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import timber.log.Timber import timber.log.Timber
import kotlin.random.Random import kotlin.random.Random
import kotlin.time.Duration.Companion.milliseconds
@Composable @Composable
fun MessagesView( fun MessagesView(
@ -380,7 +382,7 @@ private fun MessagesViewContent(
}, },
content = { paddingValues -> content = { paddingValues ->
Box(modifier = Modifier.padding(paddingValues)) { Box(modifier = Modifier.padding(paddingValues)) {
val timelineLazyListState = rememberLazyListState() val scrollBehavior = PinnedMessagesBannerViewDefaults.rememberExitOnScrollBehavior()
TimelineView( TimelineView(
state = state.timelineState, state = state.timelineState,
typingNotificationState = state.typingNotificationState, typingNotificationState = state.typingNotificationState,
@ -395,18 +397,21 @@ private fun MessagesViewContent(
onReadReceiptClick = onReadReceiptClick, onReadReceiptClick = onReadReceiptClick,
forceJumpToBottomVisibility = forceJumpToBottomVisibility, forceJumpToBottomVisibility = forceJumpToBottomVisibility,
onJoinCallClick = onJoinCallClick, onJoinCallClick = onJoinCallClick,
lazyListState = timelineLazyListState, nestedScrollConnection = scrollBehavior.nestedScrollConnection,
) )
AnimatedVisibility( AnimatedVisibility(
visible = state.pinnedMessagesBannerState.displayBanner && timelineLazyListState.isScrollingUp(), visible = state.pinnedMessagesBannerState.displayBanner && scrollBehavior.isVisible,
enter = expandVertically(), enter = expandVertically(),
exit = shrinkVertically(), exit = shrinkVertically(),
) { ) {
fun focusOnPinnedEvent(eventId: EventId) {
state.timelineState.eventSink(
TimelineEvents.FocusOnEvent(eventId = eventId, debounce = 200.milliseconds)
)
}
PinnedMessagesBannerView( PinnedMessagesBannerView(
state = state.pinnedMessagesBannerState, state = state.pinnedMessagesBannerState,
onClick = { pinnedEventId -> onClick = ::focusOnPinnedEvent,
//state.timelineState.eventSink(TimelineEvents.FocusOnEvent(pinnedEventId))
},
onViewAllClick = onViewAllPinnedMessagesClick, onViewAllClick = onViewAllPinnedMessagesClick,
) )
} }

View file

@ -44,7 +44,6 @@ class PinnedMessagesBannerPresenter @Inject constructor(
private val itemFactory: PinnedMessagesBannerItemFactory, private val itemFactory: PinnedMessagesBannerItemFactory,
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
) : Presenter<PinnedMessagesBannerState> { ) : Presenter<PinnedMessagesBannerState> {
@Composable @Composable
override fun present(): PinnedMessagesBannerState { override fun present(): PinnedMessagesBannerState {
var pinnedItems by remember { var pinnedItems by remember {
@ -109,7 +108,8 @@ class PinnedMessagesBannerPresenter @Inject constructor(
} }
.onEach { newItems -> .onEach { newItems ->
updatedOnItemsChange(newItems) updatedOnItemsChange(newItems)
}.onCompletion { }
.onCompletion {
pinnedEventsTimeline.close() pinnedEventsTimeline.close()
} }
.launchIn(this) .launchIn(this)

View file

@ -33,13 +33,19 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.shadow import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color 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.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@ -197,6 +203,34 @@ private fun PinnedMessageItem(
} }
} }
@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
}
}
}
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun PinnedMessagesBannerViewPreview(@PreviewParameter(PinnedMessagesBannerStateProvider::class) state: PinnedMessagesBannerState) = ElementPreview { internal fun PinnedMessagesBannerViewPreview(@PreviewParameter(PinnedMessagesBannerStateProvider::class) state: PinnedMessagesBannerState) = ElementPreview {

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.core.EventId
import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.Timeline
import kotlin.time.Duration
sealed interface TimelineEvents { sealed interface TimelineEvents {
data class OnScrollFinished(val firstIndex: Int) : 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 ClearFocusRequestState : TimelineEvents
data object OnFocusEventRender : TimelineEvents data object OnFocusEventRender : TimelineEvents
data object JumpToLive : TimelineEvents data object JumpToLive : TimelineEvents

View file

@ -50,6 +50,7 @@ import io.element.android.libraries.matrix.ui.room.canSendMessageAsState
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@ -136,13 +137,8 @@ class TimelinePresenter @AssistedInject constructor(
is TimelineEvents.EditPoll -> { is TimelineEvents.EditPoll -> {
navigator.onEditPollClick(event.pollStartId) navigator.onEditPollClick(event.pollStartId)
} }
is TimelineEvents.FocusOnEvent -> localScope.launch { is TimelineEvents.FocusOnEvent -> {
if (timelineItemIndexer.isKnown(event.eventId)) { focusRequestState.value = FocusRequestState.Requested(event.eventId, event.debounce)
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.OnFocusEventRender -> { is TimelineEvents.OnFocusEventRender -> {
focusRequestState.value = focusRequestState.value.onFocusEventRender() focusRequestState.value = focusRequestState.value.onFocusEventRender()
@ -157,18 +153,29 @@ class TimelinePresenter @AssistedInject constructor(
} }
LaunchedEffect(focusRequestState.value) { LaunchedEffect(focusRequestState.value) {
val currentFocusRequestState = focusRequestState.value when (val currentFocusRequestState = focusRequestState.value) {
if (currentFocusRequestState is FocusRequestState.Loading) { is FocusRequestState.Requested -> {
val eventId = currentFocusRequestState.eventId delay(currentFocusRequestState.debounce)
timelineController.focusOnEvent(eventId) if (timelineItemIndexer.isKnown(currentFocusRequestState.eventId)) {
.fold( val index = timelineItemIndexer.indexOf(currentFocusRequestState.eventId)
onSuccess = { focusRequestState.value = FocusRequestState.Success(eventId = currentFocusRequestState.eventId, index = index)
focusRequestState.value = FocusRequestState.Success(eventId = eventId) } else {
}, focusRequestState.value = FocusRequestState.Loading(eventId = currentFocusRequestState.eventId)
onFailure = { }
focusRequestState.value = FocusRequestState.Failure(throwable = it) }
} 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.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlin.time.Duration
@Immutable @Immutable
data class TimelineState( data class TimelineState(
@ -39,6 +40,7 @@ data class TimelineState(
@Immutable @Immutable
sealed interface FocusRequestState { sealed interface FocusRequestState {
data object None : FocusRequestState data object None : FocusRequestState
data class Requested(val eventId: EventId, val debounce: Duration) : FocusRequestState
data class Loading(val eventId: EventId) : FocusRequestState data class Loading(val eventId: EventId) : FocusRequestState
data class Success( data class Success(
val eventId: EventId, val eventId: EventId,
@ -54,6 +56,7 @@ sealed interface FocusRequestState {
fun eventId(): EventId? { fun eventId(): EventId? {
return when (this) { return when (this) {
is Requested -> eventId
is Loading -> eventId is Loading -> eventId
is Success -> eventId is Success -> eventId
else -> null else -> null

View file

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

View file

@ -75,6 +75,7 @@ import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import java.util.Date import java.util.Date
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
private const val FAKE_UNIQUE_ID = "FAKE_UNIQUE_ID" 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 { }.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID)) 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 -> awaitItem().also { state ->
assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID) assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID)
assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Loading(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 { }.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID)) 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 -> awaitItem().also { state ->
assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID) assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID)
assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Success(AN_EVENT_ID, 0)) 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 { }.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
initialState.eventSink(TimelineEvents.FocusOnEvent(AN_EVENT_ID)) 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 -> awaitItem().also { state ->
assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID) assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID)
assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Loading(AN_EVENT_ID)) assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Loading(AN_EVENT_ID))

View file

@ -51,7 +51,6 @@ class DefaultPinnedMessagesBannerFormatter @Inject constructor(
private val sp: StringProvider, private val sp: StringProvider,
private val permalinkParser: PermalinkParser, private val permalinkParser: PermalinkParser,
) : PinnedMessagesBannerFormatter { ) : PinnedMessagesBannerFormatter {
override fun format(event: EventTimelineItem): CharSequence { override fun format(event: EventTimelineItem): CharSequence {
return when (val content = event.content) { return when (val content = event.content) {
is MessageContent -> processMessageContents(event, content) is MessageContent -> processMessageContents(event, content)
@ -77,7 +76,6 @@ class DefaultPinnedMessagesBannerFormatter @Inject constructor(
event: EventTimelineItem, event: EventTimelineItem,
messageContent: MessageContent, messageContent: MessageContent,
): CharSequence { ): CharSequence {
return when (val messageType: MessageType = messageContent.type) { return when (val messageType: MessageType = messageContent.type) {
is EmoteMessageType -> { is EmoteMessageType -> {
val senderDisambiguatedDisplayName = event.senderProfile.getDisambiguatedDisplayName(event.sender) val senderDisambiguatedDisplayName = event.senderProfile.getDisambiguatedDisplayName(event.sender)

View file

@ -16,11 +16,6 @@
package io.element.android.libraries.eventformatter.impl 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 com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter