Pinned events : handle focus on pinned event
This commit is contained in:
@@ -17,7 +17,6 @@
|
||||
package io.element.android.features.messages.impl
|
||||
|
||||
import android.os.Parcelable
|
||||
import android.widget.Toast
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
@@ -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
|
||||
@@ -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.MessageComposerView
|
||||
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.components.JoinCallMenuItem
|
||||
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.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(
|
||||
@@ -380,7 +382,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,
|
||||
@@ -395,18 +397,21 @@ private fun MessagesViewContent(
|
||||
onReadReceiptClick = onReadReceiptClick,
|
||||
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
|
||||
onJoinCallClick = onJoinCallClick,
|
||||
lazyListState = timelineLazyListState,
|
||||
nestedScrollConnection = scrollBehavior.nestedScrollConnection,
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = state.pinnedMessagesBannerState.displayBanner && timelineLazyListState.isScrollingUp(),
|
||||
visible = state.pinnedMessagesBannerState.displayBanner && scrollBehavior.isVisible,
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically(),
|
||||
) {
|
||||
fun focusOnPinnedEvent(eventId: EventId) {
|
||||
state.timelineState.eventSink(
|
||||
TimelineEvents.FocusOnEvent(eventId = eventId, debounce = 200.milliseconds)
|
||||
)
|
||||
}
|
||||
PinnedMessagesBannerView(
|
||||
state = state.pinnedMessagesBannerState,
|
||||
onClick = { pinnedEventId ->
|
||||
//state.timelineState.eventSink(TimelineEvents.FocusOnEvent(pinnedEventId))
|
||||
},
|
||||
onClick = ::focusOnPinnedEvent,
|
||||
onViewAllClick = onViewAllPinnedMessagesClick,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -44,7 +44,6 @@ class PinnedMessagesBannerPresenter @Inject constructor(
|
||||
private val itemFactory: PinnedMessagesBannerItemFactory,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
) : Presenter<PinnedMessagesBannerState> {
|
||||
|
||||
@Composable
|
||||
override fun present(): PinnedMessagesBannerState {
|
||||
var pinnedItems by remember {
|
||||
@@ -109,7 +108,8 @@ class PinnedMessagesBannerPresenter @Inject constructor(
|
||||
}
|
||||
.onEach { newItems ->
|
||||
updatedOnItemsChange(newItems)
|
||||
}.onCompletion {
|
||||
}
|
||||
.onCompletion {
|
||||
pinnedEventsTimeline.close()
|
||||
}
|
||||
.launchIn(this)
|
||||
|
||||
@@ -33,13 +33,19 @@ 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.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
|
||||
@@ -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
|
||||
@Composable
|
||||
internal fun PinnedMessagesBannerViewPreview(@PreviewParameter(PinnedMessagesBannerStateProvider::class) state: PinnedMessagesBannerState) = ElementPreview {
|
||||
|
||||
@@ -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,6 +50,7 @@ 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
|
||||
@@ -136,13 +137,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 +153,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),
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -51,7 +51,6 @@ 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)
|
||||
@@ -77,7 +76,6 @@ class DefaultPinnedMessagesBannerFormatter @Inject constructor(
|
||||
event: EventTimelineItem,
|
||||
messageContent: MessageContent,
|
||||
): CharSequence {
|
||||
|
||||
return when (val messageType: MessageType = messageContent.type) {
|
||||
is EmoteMessageType -> {
|
||||
val senderDisambiguatedDisplayName = event.senderProfile.getDisambiguatedDisplayName(event.sender)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user