Merge pull request #3037 from element-hq/feature/fga/timeline_cancelable_focus

Feature/fga/timeline cancelable focus
This commit is contained in:
ganfra 2024-06-18 10:59:54 +02:00 committed by GitHub
commit 93ccf07ad4
13 changed files with 121 additions and 62 deletions

1
changelog.d/2876.misc Normal file
View file

@ -0,0 +1 @@
Allow cancelling jump to event in timeline.

View file

@ -292,7 +292,7 @@ private fun AttachmentStateView(
is AttachmentsState.Sending.Processing -> ProgressDialogType.Indeterminate is AttachmentsState.Sending.Processing -> ProgressDialogType.Indeterminate
}, },
text = stringResource(id = CommonStrings.common_sending), text = stringResource(id = CommonStrings.common_sending),
isCancellable = true, showCancelButton = true,
onDismissRequest = onCancel, onDismissRequest = onCancel,
) )
} }

View file

@ -98,7 +98,7 @@ private fun AttachmentSendStateView(
SendActionState.Sending.Processing -> ProgressDialogType.Indeterminate SendActionState.Sending.Processing -> ProgressDialogType.Indeterminate
}, },
text = stringResource(id = CommonStrings.common_sending), text = stringResource(id = CommonStrings.common_sending),
isCancellable = true, showCancelButton = true,
onDismissRequest = onDismissClick, onDismissRequest = onDismissClick,
) )
} }

View file

@ -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
/** /**

View file

@ -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
}
}

View file

@ -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

View file

@ -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(

View file

@ -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)
) )

View file

@ -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"),

View file

@ -20,6 +20,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
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.window.DialogProperties
import io.element.android.features.messages.impl.timeline.FocusRequestState import io.element.android.features.messages.impl.timeline.FocusRequestState
import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
@ -48,8 +49,12 @@ fun FocusRequestStateView(
modifier = modifier, modifier = modifier,
) )
} }
FocusRequestState.Fetching -> { is FocusRequestState.Loading -> {
ProgressDialog(modifier = modifier, onDismissRequest = onClearFocusRequestState) ProgressDialog(
modifier = modifier,
properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = true),
onDismissRequest = onClearFocusRequestState,
)
} }
else -> Unit else -> Unit
} }

View file

@ -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)

View file

@ -51,7 +51,8 @@ fun ProgressDialog(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
text: String? = null, text: String? = null,
type: ProgressDialogType = ProgressDialogType.Indeterminate, type: ProgressDialogType = ProgressDialogType.Indeterminate,
isCancellable: Boolean = false, properties: DialogProperties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false),
showCancelButton: Boolean = false,
onDismissRequest: () -> Unit = {}, onDismissRequest: () -> Unit = {},
) { ) {
DisposableEffect(Unit) { DisposableEffect(Unit) {
@ -61,12 +62,12 @@ fun ProgressDialog(
} }
Dialog( Dialog(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false) properties = properties,
) { ) {
ProgressDialogContent( ProgressDialogContent(
modifier = modifier, modifier = modifier,
text = text, text = text,
isCancellable = isCancellable, showCancelButton = showCancelButton,
onCancelClick = onDismissRequest, onCancelClick = onDismissRequest,
progressIndicator = { progressIndicator = {
when (type) { when (type) {
@ -97,7 +98,7 @@ sealed interface ProgressDialogType {
private fun ProgressDialogContent( private fun ProgressDialogContent(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
text: String? = null, text: String? = null,
isCancellable: Boolean = false, showCancelButton: Boolean = false,
onCancelClick: () -> Unit = {}, onCancelClick: () -> Unit = {},
progressIndicator: @Composable () -> Unit = { progressIndicator: @Composable () -> Unit = {
CircularProgressIndicator( CircularProgressIndicator(
@ -125,7 +126,7 @@ private fun ProgressDialogContent(
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
) )
} }
if (isCancellable) { if (showCancelButton) {
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
Box( Box(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@ -145,12 +146,12 @@ private fun ProgressDialogContent(
@Composable @Composable
internal fun ProgressDialogContentPreview() = ElementThemedPreview { internal fun ProgressDialogContentPreview() = ElementThemedPreview {
DialogPreview { DialogPreview {
ProgressDialogContent(text = "test dialog content", isCancellable = true) ProgressDialogContent(text = "test dialog content", showCancelButton = true)
} }
} }
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun ProgressDialogPreview() = ElementPreview { internal fun ProgressDialogPreview() = ElementPreview {
ProgressDialog(text = "test dialog content", isCancellable = true) ProgressDialog(text = "test dialog content", showCancelButton = true)
} }

View file

@ -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
}
} }
} }