Timeline : let FocusOnEvent be cancellable and refactor a bit focus states.
This commit is contained in:
parent
4f3cb5a1e5
commit
56251f805c
10 changed files with 105 additions and 52 deletions
1
changelog.d/2876.misc
Normal file
1
changelog.d/2876.misc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Allow cancelling jump to event in timeline.
|
||||||
|
|
@ -23,6 +23,7 @@ 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) : TimelineEvents
|
||||||
data object ClearFocusRequestState : TimelineEvents
|
data object ClearFocusRequestState : TimelineEvents
|
||||||
|
data object OnFocusEventRender : TimelineEvents
|
||||||
data object JumpToLive : TimelineEvents
|
data object JumpToLive : TimelineEvents
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -76,9 +76,6 @@ class TimelinePresenter @AssistedInject constructor(
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): TimelineState {
|
override fun present(): TimelineState {
|
||||||
val localScope = rememberCoroutineScope()
|
val localScope = rememberCoroutineScope()
|
||||||
val focusedEventId: MutableState<EventId?> = rememberSaveable {
|
|
||||||
mutableStateOf(null)
|
|
||||||
}
|
|
||||||
val focusRequestState: MutableState<FocusRequestState> = remember {
|
val focusRequestState: MutableState<FocusRequestState> = remember {
|
||||||
mutableStateOf(FocusRequestState.None)
|
mutableStateOf(FocusRequestState.None)
|
||||||
}
|
}
|
||||||
|
|
@ -139,23 +136,16 @@ class TimelinePresenter @AssistedInject constructor(
|
||||||
navigator.onEditPollClick(event.pollStartId)
|
navigator.onEditPollClick(event.pollStartId)
|
||||||
}
|
}
|
||||||
is TimelineEvents.FocusOnEvent -> localScope.launch {
|
is TimelineEvents.FocusOnEvent -> localScope.launch {
|
||||||
focusedEventId.value = event.eventId
|
|
||||||
if (timelineItemIndexer.isKnown(event.eventId)) {
|
if (timelineItemIndexer.isKnown(event.eventId)) {
|
||||||
val index = timelineItemIndexer.indexOf(event.eventId)
|
val index = timelineItemIndexer.indexOf(event.eventId)
|
||||||
focusRequestState.value = FocusRequestState.Cached(index)
|
focusRequestState.value = FocusRequestState.Success(eventId = event.eventId, index = index)
|
||||||
} else {
|
} else {
|
||||||
focusRequestState.value = FocusRequestState.Fetching
|
focusRequestState.value = FocusRequestState.Loading(eventId = event.eventId)
|
||||||
timelineController.focusOnEvent(event.eventId)
|
|
||||||
.fold(
|
|
||||||
onSuccess = {
|
|
||||||
focusRequestState.value = FocusRequestState.Fetched
|
|
||||||
},
|
|
||||||
onFailure = {
|
|
||||||
focusRequestState.value = FocusRequestState.Failure(it)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
is TimelineEvents.OnFocusEventRender -> {
|
||||||
|
focusRequestState.value = focusRequestState.value.onFocusEventRender()
|
||||||
|
}
|
||||||
is TimelineEvents.ClearFocusRequestState -> {
|
is TimelineEvents.ClearFocusRequestState -> {
|
||||||
focusRequestState.value = FocusRequestState.None
|
focusRequestState.value = FocusRequestState.None
|
||||||
}
|
}
|
||||||
|
|
@ -165,16 +155,33 @@ 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)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(timelineItems.size) {
|
LaunchedEffect(timelineItems.size) {
|
||||||
computeNewItemState(timelineItems, prevMostRecentItemId, newEventState)
|
computeNewItemState(timelineItems, prevMostRecentItemId, newEventState)
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(timelineItems.size, focusRequestState.value, focusedEventId.value) {
|
LaunchedEffect(timelineItems.size, focusRequestState.value) {
|
||||||
val currentFocusedEventId = focusedEventId.value
|
val currentFocusRequestState = focusRequestState.value
|
||||||
if (focusRequestState.value is FocusRequestState.Fetched && currentFocusedEventId != null) {
|
if (currentFocusRequestState is FocusRequestState.Success && !currentFocusRequestState.isIndexed) {
|
||||||
if (timelineItemIndexer.isKnown(currentFocusedEventId)) {
|
val eventId = currentFocusRequestState.eventId
|
||||||
val index = timelineItemIndexer.indexOf(currentFocusedEventId)
|
if (timelineItemIndexer.isKnown(eventId)) {
|
||||||
focusRequestState.value = FocusRequestState.Cached(index)
|
val index = timelineItemIndexer.indexOf(eventId)
|
||||||
|
focusRequestState.value = FocusRequestState.Success(eventId = eventId, index = index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -208,7 +215,6 @@ class TimelinePresenter @AssistedInject constructor(
|
||||||
renderReadReceipts = renderReadReceipts,
|
renderReadReceipts = renderReadReceipts,
|
||||||
newEventState = newEventState.value,
|
newEventState = newEventState.value,
|
||||||
isLive = isLive,
|
isLive = isLive,
|
||||||
focusedEventId = focusedEventId.value,
|
|
||||||
focusRequestState = focusRequestState.value,
|
focusRequestState = focusRequestState.value,
|
||||||
eventSink = { handleEvents(it) }
|
eventSink = { handleEvents(it) }
|
||||||
)
|
)
|
||||||
|
|
@ -278,3 +284,10 @@ class TimelinePresenter @AssistedInject constructor(
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun FocusRequestState.onFocusEventRender(): FocusRequestState {
|
||||||
|
return when (this) {
|
||||||
|
is FocusRequestState.Success -> copy(rendered = true)
|
||||||
|
else -> this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,20 +29,36 @@ data class TimelineState(
|
||||||
val renderReadReceipts: Boolean,
|
val renderReadReceipts: Boolean,
|
||||||
val newEventState: NewEventState,
|
val newEventState: NewEventState,
|
||||||
val isLive: Boolean,
|
val isLive: Boolean,
|
||||||
val focusedEventId: EventId?,
|
|
||||||
val focusRequestState: FocusRequestState,
|
val focusRequestState: FocusRequestState,
|
||||||
val eventSink: (TimelineEvents) -> Unit,
|
val eventSink: (TimelineEvents) -> Unit,
|
||||||
) {
|
) {
|
||||||
val hasAnyEvent = timelineItems.any { it is TimelineItem.Event }
|
val hasAnyEvent = timelineItems.any { it is TimelineItem.Event }
|
||||||
|
val focusedEventId = focusRequestState.eventId()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
sealed interface FocusRequestState {
|
sealed interface FocusRequestState {
|
||||||
data object None : FocusRequestState
|
data object None : FocusRequestState
|
||||||
data class Cached(val index: Int) : FocusRequestState
|
data class Loading(val eventId: EventId) : FocusRequestState
|
||||||
data object Fetching : FocusRequestState
|
data class Success(
|
||||||
data object Fetched : FocusRequestState
|
val eventId: EventId,
|
||||||
|
val index: Int = -1,
|
||||||
|
// This is used to know if the event has been rendered yet.
|
||||||
|
val rendered: Boolean = false,
|
||||||
|
) : FocusRequestState {
|
||||||
|
val isIndexed
|
||||||
|
get() = index != -1
|
||||||
|
}
|
||||||
|
|
||||||
data class Failure(val throwable: Throwable) : FocusRequestState
|
data class Failure(val throwable: Throwable) : FocusRequestState
|
||||||
|
|
||||||
|
fun eventId(): EventId? {
|
||||||
|
return when (this) {
|
||||||
|
is Loading -> eventId
|
||||||
|
is Success -> eventId
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
|
|
|
||||||
|
|
@ -51,16 +51,23 @@ fun aTimelineState(
|
||||||
focusedEventIndex: Int = -1,
|
focusedEventIndex: Int = -1,
|
||||||
isLive: Boolean = true,
|
isLive: Boolean = true,
|
||||||
eventSink: (TimelineEvents) -> Unit = {},
|
eventSink: (TimelineEvents) -> Unit = {},
|
||||||
) = TimelineState(
|
): TimelineState {
|
||||||
timelineItems = timelineItems,
|
val focusedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().getOrNull(focusedEventIndex)?.eventId
|
||||||
timelineRoomInfo = timelineRoomInfo,
|
val focusRequestState = if (focusedEventId != null) {
|
||||||
renderReadReceipts = renderReadReceipts,
|
FocusRequestState.Success(focusedEventId, focusedEventIndex)
|
||||||
newEventState = NewEventState.None,
|
} else {
|
||||||
isLive = isLive,
|
FocusRequestState.None
|
||||||
focusedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().getOrNull(focusedEventIndex)?.eventId,
|
}
|
||||||
focusRequestState = FocusRequestState.None,
|
return TimelineState(
|
||||||
eventSink = eventSink,
|
timelineItems = timelineItems,
|
||||||
)
|
timelineRoomInfo = timelineRoomInfo,
|
||||||
|
renderReadReceipts = renderReadReceipts,
|
||||||
|
newEventState = NewEventState.None,
|
||||||
|
isLive = isLive,
|
||||||
|
focusRequestState = focusRequestState,
|
||||||
|
eventSink = eventSink,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList<TimelineItem> {
|
internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList<TimelineItem> {
|
||||||
return persistentListOf(
|
return persistentListOf(
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,14 @@ fun TimelineView(
|
||||||
state.eventSink(TimelineEvents.OnScrollFinished(firstVisibleIndex))
|
state.eventSink(TimelineEvents.OnScrollFinished(firstVisibleIndex))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun onFocusEventRender() {
|
||||||
|
state.eventSink(TimelineEvents.OnFocusEventRender)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onJumpToLive() {
|
||||||
|
state.eventSink(TimelineEvents.JumpToLive)
|
||||||
|
}
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val lazyListState = rememberLazyListState()
|
val lazyListState = rememberLazyListState()
|
||||||
// Disable reverse layout when TalkBack is enabled to avoid incorrect ordering issues seen in the current Compose UI version
|
// Disable reverse layout when TalkBack is enabled to avoid incorrect ordering issues seen in the current Compose UI version
|
||||||
|
|
@ -167,8 +175,8 @@ fun TimelineView(
|
||||||
isLive = state.isLive,
|
isLive = state.isLive,
|
||||||
focusRequestState = state.focusRequestState,
|
focusRequestState = state.focusRequestState,
|
||||||
onScrollFinishAt = ::onScrollFinishAt,
|
onScrollFinishAt = ::onScrollFinishAt,
|
||||||
onClearFocusRequestState = ::clearFocusRequestState,
|
onJumpToLive = ::onJumpToLive,
|
||||||
onJumpToLive = { state.eventSink(TimelineEvents.JumpToLive) },
|
onFocusEventRender = ::onFocusEventRender,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -182,9 +190,9 @@ private fun BoxScope.TimelineScrollHelper(
|
||||||
isLive: Boolean,
|
isLive: Boolean,
|
||||||
forceJumpToBottomVisibility: Boolean,
|
forceJumpToBottomVisibility: Boolean,
|
||||||
focusRequestState: FocusRequestState,
|
focusRequestState: FocusRequestState,
|
||||||
onClearFocusRequestState: () -> Unit,
|
|
||||||
onScrollFinishAt: (Int) -> Unit,
|
onScrollFinishAt: (Int) -> Unit,
|
||||||
onJumpToLive: () -> Unit,
|
onJumpToLive: () -> Unit,
|
||||||
|
onFocusEventRender: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
val isScrollFinished by remember { derivedStateOf { !lazyListState.isScrollInProgress } }
|
val isScrollFinished by remember { derivedStateOf { !lazyListState.isScrollInProgress } }
|
||||||
|
|
@ -212,15 +220,15 @@ private fun BoxScope.TimelineScrollHelper(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val latestOnClearFocusRequestState by rememberUpdatedState(onClearFocusRequestState)
|
val latestOnFocusEventRender by rememberUpdatedState(onFocusEventRender)
|
||||||
LaunchedEffect(focusRequestState) {
|
LaunchedEffect(focusRequestState) {
|
||||||
if (focusRequestState is FocusRequestState.Cached) {
|
if (focusRequestState is FocusRequestState.Success && focusRequestState.isIndexed) {
|
||||||
if (abs(lazyListState.firstVisibleItemIndex - focusRequestState.index) < 10) {
|
if (abs(lazyListState.firstVisibleItemIndex - focusRequestState.index) < 10) {
|
||||||
lazyListState.animateScrollToItem(focusRequestState.index)
|
lazyListState.animateScrollToItem(focusRequestState.index)
|
||||||
} else {
|
} else {
|
||||||
lazyListState.scrollToItem(focusRequestState.index)
|
lazyListState.scrollToItem(focusRequestState.index)
|
||||||
}
|
}
|
||||||
latestOnClearFocusRequestState()
|
latestOnFocusEventRender()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -243,8 +251,8 @@ private fun BoxScope.TimelineScrollHelper(
|
||||||
// Use inverse of canAutoScroll otherwise we might briefly see the before the scroll animation is triggered
|
// Use inverse of canAutoScroll otherwise we might briefly see the before the scroll animation is triggered
|
||||||
isVisible = !canAutoScroll || forceJumpToBottomVisibility || !isLive,
|
isVisible = !canAutoScroll || forceJumpToBottomVisibility || !isLive,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomEnd)
|
.align(Alignment.BottomEnd)
|
||||||
.padding(end = 24.dp, bottom = 12.dp),
|
.padding(end = 24.dp, bottom = 12.dp),
|
||||||
onClick = { jumpToBottom() },
|
onClick = { jumpToBottom() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -271,8 +279,8 @@ private fun JumpToBottomButton(
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(24.dp)
|
.size(24.dp)
|
||||||
.rotate(90f),
|
.rotate(90f),
|
||||||
imageVector = CompoundIcons.ArrowRight(),
|
imageVector = CompoundIcons.ArrowRight(),
|
||||||
contentDescription = stringResource(id = CommonStrings.a11y_jump_to_bottom)
|
contentDescription = stringResource(id = CommonStrings.a11y_jump_to_bottom)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,9 @@ import io.element.android.libraries.matrix.api.room.errors.FocusEventException
|
||||||
open class FocusRequestStateProvider : PreviewParameterProvider<FocusRequestState> {
|
open class FocusRequestStateProvider : PreviewParameterProvider<FocusRequestState> {
|
||||||
override val values: Sequence<FocusRequestState>
|
override val values: Sequence<FocusRequestState>
|
||||||
get() = sequenceOf(
|
get() = sequenceOf(
|
||||||
FocusRequestState.Fetching,
|
FocusRequestState.Loading(
|
||||||
|
eventId = EventId("\$anEventId"),
|
||||||
|
),
|
||||||
FocusRequestState.Failure(
|
FocusRequestState.Failure(
|
||||||
FocusEventException.EventNotFound(
|
FocusEventException.EventNotFound(
|
||||||
eventId = EventId("\$anEventId"),
|
eventId = EventId("\$anEventId"),
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ fun FocusRequestStateView(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
FocusRequestState.Fetching -> {
|
is FocusRequestState.Loading -> {
|
||||||
ProgressDialog(
|
ProgressDialog(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = true),
|
properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = true),
|
||||||
|
|
|
||||||
|
|
@ -495,11 +495,11 @@ private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
|
||||||
initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
|
initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
|
||||||
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.Fetching)
|
assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Loading(AN_EVENT_ID))
|
||||||
}
|
}
|
||||||
skipItems(2)
|
skipItems(2)
|
||||||
awaitItem().also { state ->
|
awaitItem().also { state ->
|
||||||
assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Fetched)
|
assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Success(AN_EVENT_ID))
|
||||||
assertThat(state.timelineItems).isNotEmpty()
|
assertThat(state.timelineItems).isNotEmpty()
|
||||||
}
|
}
|
||||||
initialState.eventSink.invoke(TimelineEvents.JumpToLive)
|
initialState.eventSink.invoke(TimelineEvents.JumpToLive)
|
||||||
|
|
@ -539,7 +539,7 @@ private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
|
||||||
initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
|
initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
|
||||||
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.Cached(0))
|
assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Success(AN_EVENT_ID, 0))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -562,7 +562,7 @@ private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
|
||||||
initialState.eventSink(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
|
initialState.eventSink(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
|
||||||
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.Fetching)
|
assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Loading(AN_EVENT_ID))
|
||||||
}
|
}
|
||||||
awaitItem().also { state ->
|
awaitItem().also { state ->
|
||||||
assertThat(state.focusRequestState).isInstanceOf(FocusRequestState.Failure::class.java)
|
assertThat(state.focusRequestState).isInstanceOf(FocusRequestState.Failure::class.java)
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,7 @@ import org.matrix.rustcomponents.sdk.use
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import uniffi.matrix_sdk.RoomPowerLevelChanges
|
import uniffi.matrix_sdk.RoomPowerLevelChanges
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
import org.matrix.rustcomponents.sdk.Room as InnerRoom
|
import org.matrix.rustcomponents.sdk.Room as InnerRoom
|
||||||
import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline
|
import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline
|
||||||
|
|
||||||
|
|
@ -183,6 +184,10 @@ class RustMatrixRoom(
|
||||||
}
|
}
|
||||||
}.mapFailure {
|
}.mapFailure {
|
||||||
it.toFocusEventException()
|
it.toFocusEventException()
|
||||||
|
}.onFailure {
|
||||||
|
if (it is CancellationException) {
|
||||||
|
throw it
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue