Add nicer snackbar propagation.

This commit is contained in:
Alex Hart
2025-12-17 09:11:50 -04:00
committed by jeffrey-signal
parent a3e8ca8d33
commit d76eb9a9e4
15 changed files with 669 additions and 96 deletions

View File

@@ -0,0 +1,33 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.snackbars
import androidx.compose.material3.SnackbarDuration
import androidx.fragment.app.Fragment
import com.google.android.material.snackbar.Snackbar
fun Fragment.makeSnackbar(state: SnackbarState) {
if (view == null) {
return
}
val snackbar = Snackbar.make(
requireView(),
state.message,
when (state.duration) {
SnackbarDuration.Short -> Snackbar.LENGTH_SHORT
SnackbarDuration.Long -> Snackbar.LENGTH_LONG
SnackbarDuration.Indefinite -> Snackbar.LENGTH_INDEFINITE
}
)
state.actionState?.let { actionState ->
snackbar.setAction(actionState.action) { actionState.onActionClick() }
snackbar.setActionTextColor(requireContext().getColor(actionState.color))
}
snackbar.show()
}

View File

@@ -0,0 +1,17 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.snackbars
/**
* Marker interface for identifying snackbar host locations.
*
* Implement this interface to define distinct snackbar display locations within the app.
* When a [SnackbarState] is emitted, its [SnackbarState.hostKey] is used to route the
* snackbar to the appropriate registered consumer.
*/
interface SnackbarHostKey {
object Global : SnackbarHostKey
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.snackbars
import androidx.annotation.ColorRes
import androidx.compose.material3.SnackbarDuration
import org.thoughtcrime.securesms.R
/**
* Represents the state of a snackbar to be displayed.
*
* @property message The text message to display in the snackbar.
* @property actionState Optional action button configuration.
* @property showProgress Whether to show a progress indicator in the snackbar.
* @property duration How long the snackbar should be displayed.
* @property hostKey The target host where this snackbar should be displayed.
* @property fallbackKey Optional host to fallback upon if the host key is not registered. Defaults to the Global key.
*/
data class SnackbarState(
val message: String,
val actionState: ActionState? = null,
val showProgress: Boolean = false,
val duration: SnackbarDuration = SnackbarDuration.Long,
val hostKey: SnackbarHostKey,
val fallbackKey: SnackbarHostKey? = SnackbarHostKey.Global
) {
/**
* Configuration for a snackbar action button.
*
* @property action The text label for the action button.
* @property color The color resource for the action text.
* @property onActionClick Callback invoked when the action is clicked.
*/
data class ActionState(
val action: String,
@ColorRes val color: Int = R.color.core_white,
val onActionClick: () -> Unit
)
}

View File

@@ -0,0 +1,20 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.snackbars
/**
* A consumer that can display snackbar messages.
*
* Implementations are typically UI components that host a snackbar display area.
*/
fun interface SnackbarStateConsumer {
/**
* Consumes the given snackbar state.
*
* @param snackbarState The snackbar to display.
*/
fun consume(snackbarState: SnackbarState)
}

View File

@@ -0,0 +1,165 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.snackbars
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.core.util.Consumer
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ViewModel
import org.thoughtcrime.securesms.main.MainSnackbarHostKey
import java.io.Closeable
/**
* CompositionLocal providing access to the [SnackbarStateConsumerRegistry].
*/
val LocalSnackbarStateConsumerRegistry = staticCompositionLocalOf<SnackbarStateConsumerRegistry> {
error("No SnackbarStateConsumerRegistry provided")
}
@Composable
fun rememberSnackbarState(
key: SnackbarHostKey
): State<SnackbarState?> {
val state: MutableState<SnackbarState?> = remember(key) { mutableStateOf(null) }
val registry = LocalSnackbarStateConsumerRegistry.current
DisposableEffect(registry, key) {
val registration = registry.register(MainSnackbarHostKey.MainChrome) {
state.value = it
}
onDispose {
registration.close()
}
}
return state
}
/**
* Registry for managing snackbar consumers tied to lifecycle-aware components.
*
* Consumers are automatically enabled when their lifecycle resumes, disabled when paused,
* and removed when destroyed.
*/
class SnackbarStateConsumerRegistry : ViewModel() {
private val entries = mutableSetOf<Entry>()
/**
* Registers a snackbar consumer for the given host and returns a [Closeable] to unregister it.
*
* The consumer starts enabled immediately. Call [Closeable.close] to unregister.
* This is useful for Compose components using DisposableEffect.
*
* If a consumer is already registered for the given host, it will be replaced.
*
* @param host The host key identifying this consumer's display location.
* @param consumer The consumer that will handle snackbar display.
* @return A [Closeable] that unregisters the consumer when closed.
*/
fun register(host: SnackbarHostKey, consumer: Consumer<SnackbarState>): Closeable {
entries.removeAll { it.host == host }
val entry = Entry(
host = host,
consumer = consumer,
enabled = true
)
entries.add(entry)
return Closeable { entries.remove(entry) }
}
/**
* Registers a snackbar consumer for the given host, bound to a lifecycle.
*
* The consumer will be automatically managed based on the lifecycle:
* - Enabled when the lifecycle is in RESUMED state
* - Disabled when paused
* - Removed when destroyed
*
* If a consumer is already registered for the given host, it will be replaced.
*
* @param host The host key identifying this consumer's display location.
* @param lifecycleOwner The lifecycle owner to bind the consumer to.
* @param consumer The consumer that will handle snackbar display.
* @throws IllegalStateException if the lifecycle is not at least CREATED.
*/
fun register(host: SnackbarHostKey, lifecycleOwner: LifecycleOwner, consumer: Consumer<SnackbarState>) {
val currentState = lifecycleOwner.lifecycle.currentState
check(currentState.isAtLeast(Lifecycle.State.CREATED)) {
"Cannot register a consumer with a lifecycle in state $currentState"
}
val closeable = register(host, consumer)
val entry = entries.find { it.host == host }!!
entry.enabled = currentState.isAtLeast(Lifecycle.State.RESUMED)
lifecycleOwner.lifecycle.addObserver(EntryLifecycleObserver(entry, closeable, lifecycleOwner))
}
/**
* Emits a snackbar state to be consumed by a registered consumer.
*
* The snackbar is first offered to the consumer registered for the matching [SnackbarState.hostKey].
* If no matching consumer is enabled, the [SnackbarState.fallbackKey] is tried next (if present).
* Finally, the snackbar is offered to the first enabled registered consumer.
*
* @param snackbarState The snackbar state to emit.
*/
fun emit(snackbarState: SnackbarState) {
val matchingEntry = entries.find { it.host == snackbarState.hostKey && it.enabled }
if (matchingEntry != null) {
matchingEntry.consumer.accept(snackbarState)
return
}
val fallbackEntry = snackbarState.fallbackKey?.let { fallback ->
entries.find { it.host == fallback && it.enabled }
}
if (fallbackEntry != null) {
fallbackEntry.consumer.accept(snackbarState)
return
}
val firstEnabled = entries.find { it.enabled }
firstEnabled?.consumer?.accept(snackbarState)
}
private class EntryLifecycleObserver(
private val entry: Entry,
private val closeable: Closeable,
private val lifecycleOwner: LifecycleOwner
) : DefaultLifecycleObserver {
override fun onResume(owner: LifecycleOwner) {
entry.enabled = true
}
override fun onPause(owner: LifecycleOwner) {
entry.enabled = false
}
override fun onDestroy(owner: LifecycleOwner) {
closeable.close()
lifecycleOwner.lifecycle.removeObserver(this)
}
}
private data class Entry(
val host: SnackbarHostKey,
val consumer: Consumer<SnackbarState>,
var enabled: Boolean
)
}