mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 21:32:34 +00:00
Merge pull request #7692 from element-hq/bma/fix7689
Fix blank screen at launch when the homeserver expires the sliding sync session
This commit is contained in:
@@ -96,6 +96,7 @@ import io.element.android.libraries.matrix.api.verification.SessionVerificationS
|
|||||||
import io.element.android.libraries.matrix.api.verification.VerificationRequest
|
import io.element.android.libraries.matrix.api.verification.VerificationRequest
|
||||||
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||||
import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService
|
import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService
|
||||||
|
import io.element.android.libraries.ui.common.nodes.EmptyState
|
||||||
import io.element.android.libraries.ui.common.nodes.emptyNode
|
import io.element.android.libraries.ui.common.nodes.emptyNode
|
||||||
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction
|
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction
|
||||||
import io.element.android.services.analytics.api.AnalyticsService
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
@@ -113,6 +114,7 @@ import java.time.Duration
|
|||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.util.Optional
|
import java.util.Optional
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
import kotlin.time.Duration.Companion.minutes
|
import kotlin.time.Duration.Companion.minutes
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
import kotlin.time.toKotlinDuration
|
import kotlin.time.toKotlinDuration
|
||||||
@@ -324,7 +326,9 @@ class LoggedInFlowNode(
|
|||||||
|
|
||||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||||
return when (navTarget) {
|
return when (navTarget) {
|
||||||
NavTarget.Placeholder -> emptyNode(buildContext)
|
// Rendered while the FtueState is Unknown. Render a loading state rather than nothing at all,
|
||||||
|
// since there is no guarantee that this state will be left.
|
||||||
|
NavTarget.Placeholder -> emptyNode(buildContext, state = EmptyState(delayBeforeShowingContent = 500.milliseconds))
|
||||||
NavTarget.LoggedInPermanent -> {
|
NavTarget.LoggedInPermanent -> {
|
||||||
val callback = object : LoggedInNode.Callback {
|
val callback = object : LoggedInNode.Callback {
|
||||||
override fun navigateToNotificationTroubleshoot() {
|
override fun navigateToNotificationTroubleshoot() {
|
||||||
|
|||||||
@@ -28,10 +28,14 @@ import kotlinx.coroutines.flow.combine
|
|||||||
import kotlinx.coroutines.flow.debounce
|
import kotlinx.coroutines.flow.debounce
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onCompletion
|
import kotlinx.coroutines.flow.onCompletion
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
@@ -60,6 +64,12 @@ class SyncOrchestrator(
|
|||||||
|
|
||||||
private val started = AtomicBoolean(false)
|
private val started = AtomicBoolean(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of consecutive attempts at restarting a sync service which stopped on its own.
|
||||||
|
* Reset once the sync service has been running for [RESTART_BACKOFF_RESET_DELAY].
|
||||||
|
*/
|
||||||
|
private val restartAttempt = AtomicInteger(0)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starting observing the app state and network state to start/stop the sync service.
|
* Starting observing the app state and network state to start/stop the sync service.
|
||||||
*
|
*
|
||||||
@@ -91,6 +101,17 @@ class SyncOrchestrator(
|
|||||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||||
internal fun observeStates() = coroutineScope.launch {
|
internal fun observeStates() = coroutineScope.launch {
|
||||||
Timber.tag(tag).d("start observing the app and network state")
|
Timber.tag(tag).d("start observing the app and network state")
|
||||||
|
// Reset the restart backoff once the sync service has been steadily running for a while. Note that
|
||||||
|
// SyncService.startSync() moves the state to Running right away, so resetting on any Running value
|
||||||
|
// would defeat the backoff when the sync service dies again immediately after each restart.
|
||||||
|
syncService.syncState
|
||||||
|
.debounce(RESTART_BACKOFF_RESET_DELAY)
|
||||||
|
.onEach { syncState ->
|
||||||
|
if (syncState == SyncState.Running) {
|
||||||
|
restartAttempt.set(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.launchIn(this)
|
||||||
val isAppActiveFlows = listOf(
|
val isAppActiveFlows = listOf(
|
||||||
appForegroundStateService.isInForeground,
|
appForegroundStateService.isInForeground,
|
||||||
appForegroundStateService.isInCall,
|
appForegroundStateService.isInCall,
|
||||||
@@ -117,14 +138,26 @@ class SyncOrchestrator(
|
|||||||
SyncStateAction.StopSync
|
SyncStateAction.StopSync
|
||||||
} else if (syncState == SyncState.Idle && isAppActive && isNetworkAvailable) {
|
} else if (syncState == SyncState.Idle && isAppActive && isNetworkAvailable) {
|
||||||
SyncStateAction.StartSync
|
SyncStateAction.StartSync
|
||||||
|
} else if (syncState == SyncState.Error && isAppActive && isNetworkAvailable) {
|
||||||
|
// The sync service has given up, for instance because the homeserver has expired our sliding sync
|
||||||
|
// session and answered 400 M_UNKNOWN_POS. Nothing else will ever restart it, so do it from here,
|
||||||
|
// else the application is stuck until its data is cleared.
|
||||||
|
SyncStateAction.RestartSync
|
||||||
} else {
|
} else {
|
||||||
SyncStateAction.NoOp
|
SyncStateAction.NoOp
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.debounce { action ->
|
.debounce { action ->
|
||||||
// Don't stop the sync immediately, wait a bit to avoid starting/stopping the sync too often
|
when (action) {
|
||||||
if (action == SyncStateAction.StopSync) 3.seconds else 0.seconds
|
// Don't stop the sync immediately, wait a bit to avoid starting/stopping the sync too often
|
||||||
|
SyncStateAction.StopSync -> 3.seconds
|
||||||
|
// Back off before restarting a dead sync service, to avoid hammering a failing homeserver.
|
||||||
|
// A state change in the meantime cancels the pending restart.
|
||||||
|
SyncStateAction.RestartSync -> restartDelay(restartAttempt.get())
|
||||||
|
SyncStateAction.StartSync,
|
||||||
|
SyncStateAction.NoOp -> 0.seconds
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onCompletion {
|
.onCompletion {
|
||||||
Timber.tag(tag).d("has been stopped")
|
Timber.tag(tag).d("has been stopped")
|
||||||
@@ -134,6 +167,11 @@ class SyncOrchestrator(
|
|||||||
SyncStateAction.StartSync -> {
|
SyncStateAction.StartSync -> {
|
||||||
syncService.startSync()
|
syncService.startSync()
|
||||||
}
|
}
|
||||||
|
SyncStateAction.RestartSync -> {
|
||||||
|
val attempt = restartAttempt.incrementAndGet()
|
||||||
|
Timber.tag(tag).w("sync service is dead, restarting it (attempt $attempt)")
|
||||||
|
syncService.startSync()
|
||||||
|
}
|
||||||
SyncStateAction.StopSync -> {
|
SyncStateAction.StopSync -> {
|
||||||
syncService.stopSync()
|
syncService.stopSync()
|
||||||
}
|
}
|
||||||
@@ -143,8 +181,35 @@ class SyncOrchestrator(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal val FIRST_RESTART_DELAY = 1.seconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exponential backoff, from [FIRST_RESTART_DELAY], doubling on every attempt, up to [FIRST_RESTART_DELAY * 32].
|
||||||
|
*/
|
||||||
|
@VisibleForTesting
|
||||||
|
internal val RESTART_BACKOFF_DELAYS = listOf(
|
||||||
|
FIRST_RESTART_DELAY,
|
||||||
|
FIRST_RESTART_DELAY * 2,
|
||||||
|
FIRST_RESTART_DELAY * 4,
|
||||||
|
FIRST_RESTART_DELAY * 8,
|
||||||
|
FIRST_RESTART_DELAY * 16,
|
||||||
|
FIRST_RESTART_DELAY * 32,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Duration during which the sync service has to stay running before the restart backoff is reset.
|
||||||
|
*/
|
||||||
|
@VisibleForTesting
|
||||||
|
internal val RESTART_BACKOFF_RESET_DELAY = 30.seconds
|
||||||
|
|
||||||
|
private fun restartDelay(attempt: Int): Duration {
|
||||||
|
return RESTART_BACKOFF_DELAYS[attempt.coerceIn(0, RESTART_BACKOFF_DELAYS.lastIndex)]
|
||||||
|
}
|
||||||
|
|
||||||
private enum class SyncStateAction {
|
private enum class SyncStateAction {
|
||||||
StartSync,
|
StartSync,
|
||||||
|
RestartSync,
|
||||||
StopSync,
|
StopSync,
|
||||||
NoOp,
|
NoOp,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
|
|
||||||
package io.element.android.appnav
|
package io.element.android.appnav
|
||||||
|
|
||||||
|
import io.element.android.appnav.session.FIRST_RESTART_DELAY
|
||||||
|
import io.element.android.appnav.session.RESTART_BACKOFF_DELAYS
|
||||||
|
import io.element.android.appnav.session.RESTART_BACKOFF_RESET_DELAY
|
||||||
import io.element.android.appnav.session.SyncOrchestrator
|
import io.element.android.appnav.session.SyncOrchestrator
|
||||||
import io.element.android.features.networkmonitor.api.NetworkStatus
|
import io.element.android.features.networkmonitor.api.NetworkStatus
|
||||||
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
|
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
|
||||||
@@ -414,6 +417,131 @@ class SyncOrchestratorTest {
|
|||||||
startSyncRecorder.assertions().isNeverCalled()
|
startSyncRecorder.assertions().isNeverCalled()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when the sync service is in error, it will be restarted after a delay`() = runTest {
|
||||||
|
val startSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||||
|
val syncService = FakeSyncService(initialSyncState = SyncState.Error).apply {
|
||||||
|
startSyncLambda = startSyncRecorder
|
||||||
|
}
|
||||||
|
val syncOrchestrator = createSyncOrchestrator(
|
||||||
|
matrixClient = FakeMatrixClient(syncService = syncService),
|
||||||
|
networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected),
|
||||||
|
appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true),
|
||||||
|
)
|
||||||
|
|
||||||
|
// We start observing
|
||||||
|
syncOrchestrator.observeStates()
|
||||||
|
|
||||||
|
// The restart does not happen right away, we back off first
|
||||||
|
advanceTimeBy(FIRST_RESTART_DELAY / 2)
|
||||||
|
startSyncRecorder.assertions().isNeverCalled()
|
||||||
|
|
||||||
|
// But it does happen once the backoff has elapsed
|
||||||
|
advanceTimeBy(FIRST_RESTART_DELAY)
|
||||||
|
startSyncRecorder.assertions().isCalledOnce()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when the sync service keeps dying, the delay between the restarts increases`() = runTest {
|
||||||
|
val startSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||||
|
val syncService = FakeSyncService(initialSyncState = SyncState.Error).apply {
|
||||||
|
startSyncLambda = startSyncRecorder
|
||||||
|
}
|
||||||
|
val syncOrchestrator = createSyncOrchestrator(
|
||||||
|
matrixClient = FakeMatrixClient(syncService = syncService),
|
||||||
|
networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected),
|
||||||
|
appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true),
|
||||||
|
)
|
||||||
|
|
||||||
|
// We start observing
|
||||||
|
syncOrchestrator.observeStates()
|
||||||
|
|
||||||
|
advanceTimeBy(FIRST_RESTART_DELAY * 2)
|
||||||
|
startSyncRecorder.assertions().isCalledOnce()
|
||||||
|
|
||||||
|
// The sync service starts, but dies again straight away
|
||||||
|
syncService.emitSyncState(SyncState.Running)
|
||||||
|
advanceTimeBy(200.milliseconds)
|
||||||
|
syncService.emitSyncState(SyncState.Error)
|
||||||
|
|
||||||
|
// The second restart is not attempted after the first delay anymore
|
||||||
|
advanceTimeBy(FIRST_RESTART_DELAY + 200.milliseconds)
|
||||||
|
startSyncRecorder.assertions().isCalledOnce()
|
||||||
|
|
||||||
|
// But after twice that delay
|
||||||
|
advanceTimeBy(FIRST_RESTART_DELAY)
|
||||||
|
startSyncRecorder.assertions().isCalledExactly(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when the sync service recovers, the delay between the restarts is reset`() = runTest {
|
||||||
|
val startSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||||
|
val syncService = FakeSyncService(initialSyncState = SyncState.Error).apply {
|
||||||
|
startSyncLambda = startSyncRecorder
|
||||||
|
}
|
||||||
|
val syncOrchestrator = createSyncOrchestrator(
|
||||||
|
matrixClient = FakeMatrixClient(syncService = syncService),
|
||||||
|
networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected),
|
||||||
|
appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true),
|
||||||
|
)
|
||||||
|
|
||||||
|
// We start observing
|
||||||
|
syncOrchestrator.observeStates()
|
||||||
|
|
||||||
|
advanceTimeBy(FIRST_RESTART_DELAY * 2)
|
||||||
|
startSyncRecorder.assertions().isCalledOnce()
|
||||||
|
|
||||||
|
// The sync service is now running, and keeps running for a while
|
||||||
|
syncService.emitSyncState(SyncState.Running)
|
||||||
|
advanceTimeBy(RESTART_BACKOFF_RESET_DELAY + 1.seconds)
|
||||||
|
startSyncRecorder.assertions().isCalledOnce()
|
||||||
|
|
||||||
|
// It dies again, and is restarted after the first delay, not a longer one
|
||||||
|
syncService.emitSyncState(SyncState.Error)
|
||||||
|
advanceTimeBy(FIRST_RESTART_DELAY / 2)
|
||||||
|
startSyncRecorder.assertions().isCalledOnce()
|
||||||
|
advanceTimeBy(FIRST_RESTART_DELAY)
|
||||||
|
startSyncRecorder.assertions().isCalledExactly(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when the sync service is in error but the app is in background, it is not restarted`() = runTest {
|
||||||
|
val startSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||||
|
val syncService = FakeSyncService(initialSyncState = SyncState.Error).apply {
|
||||||
|
startSyncLambda = startSyncRecorder
|
||||||
|
}
|
||||||
|
val syncOrchestrator = createSyncOrchestrator(
|
||||||
|
matrixClient = FakeMatrixClient(syncService = syncService),
|
||||||
|
networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected),
|
||||||
|
appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = false),
|
||||||
|
)
|
||||||
|
|
||||||
|
// We start observing
|
||||||
|
syncOrchestrator.observeStates()
|
||||||
|
|
||||||
|
advanceTimeBy(RESTART_BACKOFF_DELAYS.last() * 2)
|
||||||
|
startSyncRecorder.assertions().isNeverCalled()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when the sync service is in error but there is no network, it is not restarted`() = runTest {
|
||||||
|
val startSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||||
|
val syncService = FakeSyncService(initialSyncState = SyncState.Error).apply {
|
||||||
|
startSyncLambda = startSyncRecorder
|
||||||
|
}
|
||||||
|
val syncOrchestrator = createSyncOrchestrator(
|
||||||
|
matrixClient = FakeMatrixClient(syncService = syncService),
|
||||||
|
networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Disconnected),
|
||||||
|
appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true),
|
||||||
|
)
|
||||||
|
|
||||||
|
// We start observing
|
||||||
|
syncOrchestrator.observeStates()
|
||||||
|
|
||||||
|
advanceTimeBy(RESTART_BACKOFF_DELAYS.last() * 2)
|
||||||
|
startSyncRecorder.assertions().isNeverCalled()
|
||||||
|
}
|
||||||
|
|
||||||
private fun TestScope.createSyncOrchestrator(
|
private fun TestScope.createSyncOrchestrator(
|
||||||
matrixClient: FakeMatrixClient = FakeMatrixClient(),
|
matrixClient: FakeMatrixClient = FakeMatrixClient(),
|
||||||
networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(),
|
networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(),
|
||||||
|
|||||||
+6
-1
@@ -32,11 +32,13 @@ import io.element.android.libraries.architecture.BackstackView
|
|||||||
import io.element.android.libraries.architecture.BaseFlowNode
|
import io.element.android.libraries.architecture.BaseFlowNode
|
||||||
import io.element.android.libraries.architecture.createNode
|
import io.element.android.libraries.architecture.createNode
|
||||||
import io.element.android.libraries.di.SessionScope
|
import io.element.android.libraries.di.SessionScope
|
||||||
|
import io.element.android.libraries.ui.common.nodes.EmptyState
|
||||||
import io.element.android.libraries.ui.common.nodes.emptyNode
|
import io.element.android.libraries.ui.common.nodes.emptyNode
|
||||||
import kotlinx.coroutines.flow.filterIsInstance
|
import kotlinx.coroutines.flow.filterIsInstance
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
@ContributesNode(SessionScope::class)
|
@ContributesNode(SessionScope::class)
|
||||||
@AssistedInject
|
@AssistedInject
|
||||||
@@ -84,7 +86,10 @@ class FtueFlowNode(
|
|||||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||||
return when (navTarget) {
|
return when (navTarget) {
|
||||||
NavTarget.Placeholder -> {
|
NavTarget.Placeholder -> {
|
||||||
emptyNode(buildContext)
|
// Rendered while waiting for the initial state (see FtueStep.WaitingForInitialState).
|
||||||
|
// This can last for a while, or forever if the sync service never manages to run, so
|
||||||
|
// render a loading state rather than nothing at all.
|
||||||
|
emptyNode(buildContext, EmptyState(delayBeforeShowingContent = 500.milliseconds))
|
||||||
}
|
}
|
||||||
is NavTarget.SessionVerification -> {
|
is NavTarget.SessionVerification -> {
|
||||||
val callback = object : FtueSessionVerificationFlowNode.Callback {
|
val callback = object : FtueSessionVerificationFlowNode.Callback {
|
||||||
|
|||||||
@@ -18,4 +18,5 @@ dependencies {
|
|||||||
implementation(libs.appyx.core)
|
implementation(libs.appyx.core)
|
||||||
implementation(projects.libraries.core)
|
implementation(projects.libraries.core)
|
||||||
implementation(projects.libraries.designsystem)
|
implementation(projects.libraries.designsystem)
|
||||||
|
implementation(projects.libraries.uiStrings)
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-24
@@ -8,38 +8,23 @@
|
|||||||
|
|
||||||
package io.element.android.libraries.ui.common.nodes
|
package io.element.android.libraries.ui.common.nodes
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import com.bumble.appyx.core.modality.BuildContext
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
import com.bumble.appyx.core.node.Node
|
import com.bumble.appyx.core.node.Node
|
||||||
import com.bumble.appyx.core.node.node
|
import com.bumble.appyx.core.node.node
|
||||||
import io.element.android.compound.theme.ElementTheme
|
import kotlin.time.Duration
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ref: https://www.figma.com/design/0MMNu7cTOzLOlWb7ctTkv3/Element-X?node-id=1518-85323
|
* Ref: https://www.figma.com/design/0MMNu7cTOzLOlWb7ctTkv3/Element-X?node-id=1518-85323
|
||||||
|
*
|
||||||
|
* @param buildContext the [BuildContext] of the Node.
|
||||||
|
* @param state the [EmptyState] of the Node. Default value will render an empty node forever.
|
||||||
*/
|
*/
|
||||||
fun emptyNode(
|
fun emptyNode(
|
||||||
buildContext: BuildContext,
|
buildContext: BuildContext,
|
||||||
|
state: EmptyState = EmptyState(Duration.INFINITE),
|
||||||
): Node = node(buildContext) { modifier ->
|
): Node = node(buildContext) { modifier ->
|
||||||
EmptyView(modifier)
|
EmptyView(
|
||||||
}
|
state = state,
|
||||||
|
modifier = modifier,
|
||||||
@Composable
|
)
|
||||||
private fun EmptyView(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) = Box(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(ElementTheme.colors.bgCanvasDefault),
|
|
||||||
)
|
|
||||||
|
|
||||||
@PreviewsDayNight
|
|
||||||
@Composable
|
|
||||||
internal fun EmptyViewPreview() = ElementPreview {
|
|
||||||
EmptyView(Modifier)
|
|
||||||
}
|
}
|
||||||
|
|||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.ui.common.nodes
|
||||||
|
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property delayBeforeShowingContent when finite, a loading indicator fades in after this delay. Use it for
|
||||||
|
* placeholders which can stay on screen for a long time, or forever, else the application looks frozen.
|
||||||
|
*/
|
||||||
|
data class EmptyState(
|
||||||
|
val delayBeforeShowingContent: Duration,
|
||||||
|
)
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.ui.common.nodes
|
||||||
|
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
open class EmptyStatePreviewParam : PreviewParameterProvider<EmptyState> {
|
||||||
|
override val values: Sequence<EmptyState>
|
||||||
|
get() = sequenceOf(
|
||||||
|
EmptyState(Duration.INFINITE),
|
||||||
|
EmptyState(1.seconds),
|
||||||
|
)
|
||||||
|
}
|
||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.ui.common.nodes
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.compound.theme.ElementTheme
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
import io.element.android.libraries.designsystem.utils.DelayedVisibility
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun EmptyView(
|
||||||
|
state: EmptyState,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) = Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(ElementTheme.colors.bgCanvasDefault),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (state.delayBeforeShowingContent.isFinite()) {
|
||||||
|
DelayedVisibility(duration = state.delayBeforeShowingContent) {
|
||||||
|
LoadingContent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LoadingContent() = Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
Text(
|
||||||
|
text = stringResource(CommonStrings.common_loading),
|
||||||
|
color = ElementTheme.colors.textSecondary,
|
||||||
|
style = ElementTheme.typography.fontBodyMdRegular,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun EmptyViewPreview(
|
||||||
|
@PreviewParameter(EmptyStatePreviewParam::class) state: EmptyState
|
||||||
|
) = ElementPreview {
|
||||||
|
EmptyView(state = state)
|
||||||
|
}
|
||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Reference in New Issue
Block a user