mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-20 08:39:22 +01:00
Add nicer snackbar propagation.
This commit is contained in:
committed by
jeffrey-signal
parent
a3e8ca8d33
commit
d76eb9a9e4
@@ -51,6 +51,7 @@ import androidx.compose.material3.adaptive.layout.PaneExpansionAnchor
|
||||
import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole
|
||||
import androidx.compose.material3.adaptive.layout.rememberPaneExpansionState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
@@ -104,6 +105,10 @@ import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity.Co
|
||||
import org.thoughtcrime.securesms.components.settings.app.notifications.manual.NotificationProfileSelectionFragment
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePayComponent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePayRepository
|
||||
import org.thoughtcrime.securesms.components.snackbars.LocalSnackbarStateConsumerRegistry
|
||||
import org.thoughtcrime.securesms.components.snackbars.SnackbarHostKey
|
||||
import org.thoughtcrime.securesms.components.snackbars.SnackbarState
|
||||
import org.thoughtcrime.securesms.components.snackbars.SnackbarStateConsumerRegistry
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner
|
||||
import org.thoughtcrime.securesms.compose.SignalTheme
|
||||
@@ -134,13 +139,13 @@ import org.thoughtcrime.securesms.main.MainNavigationListLocation
|
||||
import org.thoughtcrime.securesms.main.MainNavigationRail
|
||||
import org.thoughtcrime.securesms.main.MainNavigationViewModel
|
||||
import org.thoughtcrime.securesms.main.MainSnackbar
|
||||
import org.thoughtcrime.securesms.main.MainSnackbarHostKey
|
||||
import org.thoughtcrime.securesms.main.MainToolbar
|
||||
import org.thoughtcrime.securesms.main.MainToolbarCallback
|
||||
import org.thoughtcrime.securesms.main.MainToolbarMode
|
||||
import org.thoughtcrime.securesms.main.MainToolbarState
|
||||
import org.thoughtcrime.securesms.main.MainToolbarViewModel
|
||||
import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder
|
||||
import org.thoughtcrime.securesms.main.SnackbarState
|
||||
import org.thoughtcrime.securesms.main.callNavGraphBuilder
|
||||
import org.thoughtcrime.securesms.main.chatNavGraphBuilder
|
||||
import org.thoughtcrime.securesms.main.navigateToDetailLocation
|
||||
@@ -242,6 +247,8 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
private val megaphoneActionController = MainMegaphoneActionController()
|
||||
private val mainNavigationCallback = MainNavigationCallback()
|
||||
|
||||
private val snackbarRegistry = SnackbarStateConsumerRegistry()
|
||||
|
||||
override val googlePayRepository: GooglePayRepository by lazy { GooglePayRepository(this) }
|
||||
override val googlePayResultPublisher: Subject<GooglePayComponent.GooglePayResult> = PublishSubject.create()
|
||||
|
||||
@@ -315,10 +322,12 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
this
|
||||
) { _, bundle ->
|
||||
if (bundle.getBoolean(CallQualityBottomSheetFragment.REQUEST_KEY, false)) {
|
||||
mainNavigationViewModel.setSnackbar(
|
||||
mainNavigationViewModel.snackbarRegistry.emit(
|
||||
SnackbarState(
|
||||
message = getString(R.string.CallQualitySheet__thanks_for_your_feedback),
|
||||
duration = SnackbarDuration.Short
|
||||
duration = SnackbarDuration.Short,
|
||||
hostKey = MainSnackbarHostKey.Chat,
|
||||
fallbackKey = MainSnackbarHostKey.MainChrome
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -327,7 +336,6 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
shareDataTimestampViewModel.setTimestampFromActivityCreation(savedInstanceState, intent)
|
||||
|
||||
setContent {
|
||||
val snackbar by mainNavigationViewModel.snackbar.collectAsStateWithLifecycle()
|
||||
val mainToolbarState by toolbarViewModel.state.collectAsStateWithLifecycle()
|
||||
val megaphone by mainNavigationViewModel.megaphone.collectAsStateWithLifecycle()
|
||||
val mainNavigationState by mainNavigationViewModel.mainNavigationState.collectAsStateWithLifecycle()
|
||||
@@ -367,10 +375,9 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
}
|
||||
}
|
||||
|
||||
val mainBottomChromeState = remember(mainToolbarState.destination, snackbar, mainToolbarState.mode, megaphone) {
|
||||
val mainBottomChromeState = remember(mainToolbarState.destination, mainToolbarState.mode, megaphone) {
|
||||
MainBottomChromeState(
|
||||
destination = mainToolbarState.destination,
|
||||
snackbarState = snackbar,
|
||||
mainToolbarMode = mainToolbarState.mode,
|
||||
megaphoneState = MainMegaphoneState(
|
||||
megaphone = megaphone,
|
||||
@@ -547,7 +554,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
snackbarHost = {
|
||||
if (wrappedNavigator.scaffoldValue.primary == PaneAdaptedValue.Expanded) {
|
||||
MainSnackbar(
|
||||
snackbarState = snackbar,
|
||||
hostKey = SnackbarHostKey.Global,
|
||||
onDismissed = mainBottomChromeCallback::onSnackbarDismissed,
|
||||
modifier = Modifier.navigationBarsPadding()
|
||||
)
|
||||
@@ -763,34 +770,36 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
private fun MainContainer(content: @Composable BoxWithConstraintsScope.() -> Unit) {
|
||||
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
|
||||
|
||||
SignalTheme(isDarkMode = DynamicTheme.isDarkTheme(this)) {
|
||||
val backgroundColor = if (!windowSizeClass.isSplitPane()) {
|
||||
MaterialTheme.colorScheme.surface
|
||||
} else {
|
||||
SignalTheme.colors.colorSurface1
|
||||
}
|
||||
|
||||
val modifier = when {
|
||||
windowSizeClass.isSplitPane() -> {
|
||||
Modifier
|
||||
.systemBarsPadding()
|
||||
.displayCutoutPadding()
|
||||
CompositionLocalProvider(LocalSnackbarStateConsumerRegistry provides mainNavigationViewModel.snackbarRegistry) {
|
||||
SignalTheme(isDarkMode = DynamicTheme.isDarkTheme(this)) {
|
||||
val backgroundColor = if (!windowSizeClass.isSplitPane()) {
|
||||
MaterialTheme.colorScheme.surface
|
||||
} else {
|
||||
SignalTheme.colors.colorSurface1
|
||||
}
|
||||
|
||||
else ->
|
||||
Modifier
|
||||
.windowInsetsPadding(
|
||||
WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal)
|
||||
.add(WindowInsets.displayCutout.only(WindowInsetsSides.Horizontal))
|
||||
)
|
||||
}
|
||||
val modifier = when {
|
||||
windowSizeClass.isSplitPane() -> {
|
||||
Modifier
|
||||
.systemBarsPadding()
|
||||
.displayCutoutPadding()
|
||||
}
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.background(color = backgroundColor)
|
||||
.then(modifier)
|
||||
) {
|
||||
content()
|
||||
else ->
|
||||
Modifier
|
||||
.windowInsetsPadding(
|
||||
WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal)
|
||||
.add(WindowInsets.displayCutout.only(WindowInsetsSides.Horizontal))
|
||||
)
|
||||
}
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.background(color = backgroundColor)
|
||||
.then(modifier)
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -876,24 +885,26 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
}
|
||||
|
||||
if (resultCode == RESULT_OK && requestCode == CreateSvrPinActivity.REQUEST_NEW_PIN) {
|
||||
mainNavigationViewModel.setSnackbar(SnackbarState(message = getString(R.string.ConfirmKbsPinFragment__pin_created)))
|
||||
mainNavigationViewModel.snackbarRegistry.emit(SnackbarState(message = getString(R.string.ConfirmKbsPinFragment__pin_created), hostKey = MainSnackbarHostKey.MainChrome))
|
||||
mainNavigationViewModel.onMegaphoneCompleted(Megaphones.Event.PINS_FOR_ALL)
|
||||
}
|
||||
|
||||
if (resultCode == RESULT_OK && requestCode == UsernameEditFragment.REQUEST_CODE) {
|
||||
val snackbarString = getString(R.string.ConversationListFragment_username_recovered_toast, SignalStore.account.username)
|
||||
mainNavigationViewModel.setSnackbar(
|
||||
mainNavigationViewModel.snackbarRegistry.emit(
|
||||
SnackbarState(
|
||||
message = snackbarString
|
||||
message = snackbarString,
|
||||
hostKey = MainSnackbarHostKey.MainChrome
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (resultCode == RESULT_OK && requestCode == VerifyBackupKeyActivity.REQUEST_CODE) {
|
||||
mainNavigationViewModel.setSnackbar(
|
||||
mainNavigationViewModel.snackbarRegistry.emit(
|
||||
SnackbarState(
|
||||
message = getString(R.string.VerifyBackupKey__backup_key_correct),
|
||||
duration = SnackbarDuration.Short
|
||||
duration = SnackbarDuration.Short,
|
||||
hostKey = MainSnackbarHostKey.MainChrome
|
||||
)
|
||||
)
|
||||
mainNavigationViewModel.onMegaphoneSnoozed(Megaphones.Event.VERIFY_BACKUP_KEY)
|
||||
@@ -1205,9 +1216,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
mainNavigationViewModel.onMegaphoneVisible(megaphone)
|
||||
}
|
||||
|
||||
override fun onSnackbarDismissed() {
|
||||
mainNavigationViewModel.setSnackbar(null)
|
||||
}
|
||||
override fun onSnackbarDismissed() = Unit
|
||||
}
|
||||
|
||||
inner class MainMegaphoneActionController : MegaphoneActionController {
|
||||
@@ -1220,9 +1229,10 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
}
|
||||
|
||||
override fun onMegaphoneToastRequested(string: String) {
|
||||
mainNavigationViewModel.setSnackbar(
|
||||
mainNavigationViewModel.snackbarRegistry.emit(
|
||||
SnackbarState(
|
||||
message = string
|
||||
message = string,
|
||||
hostKey = MainSnackbarHostKey.MainChrome
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import org.thoughtcrime.securesms.components.ScrollToPositionDelegate
|
||||
import org.thoughtcrime.securesms.components.ViewBinderDelegate
|
||||
import org.thoughtcrime.securesms.components.menu.ActionItem
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity
|
||||
import org.thoughtcrime.securesms.components.snackbars.SnackbarState
|
||||
import org.thoughtcrime.securesms.conversation.ConversationUpdateTick
|
||||
import org.thoughtcrime.securesms.conversation.SignalBottomActionBarController
|
||||
import org.thoughtcrime.securesms.conversation.v2.ConversationDialogs
|
||||
@@ -50,10 +51,10 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
|
||||
import org.thoughtcrime.securesms.main.MainNavigationListLocation
|
||||
import org.thoughtcrime.securesms.main.MainNavigationViewModel
|
||||
import org.thoughtcrime.securesms.main.MainSnackbarHostKey
|
||||
import org.thoughtcrime.securesms.main.MainToolbarMode
|
||||
import org.thoughtcrime.securesms.main.MainToolbarViewModel
|
||||
import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder
|
||||
import org.thoughtcrime.securesms.main.SnackbarState
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
@@ -346,9 +347,10 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
|
||||
override fun onStartAudioCallClicked(recipient: Recipient) {
|
||||
CommunicationActions.startVoiceCall(this, recipient) {
|
||||
mainNavigationViewModel.setSnackbar(
|
||||
mainNavigationViewModel.snackbarRegistry.emit(
|
||||
SnackbarState(
|
||||
getString(R.string.CommunicationActions__you_are_already_in_a_call)
|
||||
getString(R.string.CommunicationActions__you_are_already_in_a_call),
|
||||
hostKey = MainSnackbarHostKey.MainChrome
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -357,9 +359,10 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
override fun onStartVideoCallClicked(recipient: Recipient, canUserBeginCall: Boolean) {
|
||||
if (canUserBeginCall) {
|
||||
CommunicationActions.startVideoCall(this, recipient) {
|
||||
mainNavigationViewModel.setSnackbar(
|
||||
mainNavigationViewModel.snackbarRegistry.emit(
|
||||
SnackbarState(
|
||||
getString(R.string.CommunicationActions__you_are_already_in_a_call)
|
||||
getString(R.string.CommunicationActions__you_are_already_in_a_call),
|
||||
hostKey = MainSnackbarHostKey.MainChrome
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -452,10 +455,11 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
}
|
||||
|
||||
CallLogDeletionResult.Success -> {
|
||||
mainNavigationViewModel.setSnackbar(
|
||||
mainNavigationViewModel.snackbarRegistry.emit(
|
||||
SnackbarState(
|
||||
message = snackbarMessage,
|
||||
duration = SnackbarDuration.Short
|
||||
duration = SnackbarDuration.Short,
|
||||
hostKey = MainSnackbarHostKey.MainChrome
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -153,6 +153,7 @@ import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.CheckoutFlowActivity
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalFragment
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity
|
||||
import org.thoughtcrime.securesms.components.snackbars.makeSnackbar
|
||||
import org.thoughtcrime.securesms.components.spoiler.SpoilerAnnotation
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteDraft
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner
|
||||
@@ -274,6 +275,7 @@ import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModelV2
|
||||
import org.thoughtcrime.securesms.longmessage.LongMessageFragment
|
||||
import org.thoughtcrime.securesms.main.MainNavigationListLocation
|
||||
import org.thoughtcrime.securesms.main.MainNavigationViewModel
|
||||
import org.thoughtcrime.securesms.main.MainSnackbarHostKey
|
||||
import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity
|
||||
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory
|
||||
import org.thoughtcrime.securesms.mediapreview.MediaPreviewV2Activity
|
||||
@@ -1089,6 +1091,14 @@ class ConversationFragment :
|
||||
}
|
||||
}
|
||||
|
||||
mainNavigationViewModel.snackbarRegistry.register(
|
||||
MainSnackbarHostKey.Chat,
|
||||
viewLifecycleOwner
|
||||
) { state ->
|
||||
makeSnackbar(state)
|
||||
true
|
||||
}
|
||||
|
||||
menuProvider?.afterFirstRenderMode = true
|
||||
|
||||
viewLifecycleOwner.lifecycle.addObserver(LastScrolledPositionUpdater(adapter, layoutManager, viewModel))
|
||||
|
||||
@@ -32,9 +32,10 @@ import androidx.recyclerview.widget.RecyclerView;
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable;
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.snackbars.SnackbarState;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.main.MainNavigationListLocation;
|
||||
import org.thoughtcrime.securesms.main.SnackbarState;
|
||||
import org.thoughtcrime.securesms.main.MainSnackbarHostKey;
|
||||
import org.thoughtcrime.securesms.util.ConversationUtil;
|
||||
import org.thoughtcrime.securesms.util.views.Stub;
|
||||
|
||||
@@ -136,7 +137,7 @@ public class ConversationListArchiveFragment extends ConversationListFragment
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(() -> {
|
||||
mainNavigationViewModel.setSnackbar(new SnackbarState(
|
||||
mainNavigationViewModel.getSnackbarRegistry().emit(new SnackbarState(
|
||||
getResources().getQuantityString(R.plurals.ConversationListFragment_moved_conversations_to_inbox, 1, 1),
|
||||
new SnackbarState.ActionState(
|
||||
getString(R.string.ConversationListFragment_undo),
|
||||
@@ -151,7 +152,9 @@ public class ConversationListArchiveFragment extends ConversationListFragment
|
||||
}
|
||||
),
|
||||
false,
|
||||
SnackbarDuration.Long
|
||||
SnackbarDuration.Long,
|
||||
MainSnackbarHostKey.MainChrome.INSTANCE,
|
||||
null
|
||||
));
|
||||
})
|
||||
);
|
||||
|
||||
@@ -107,6 +107,7 @@ import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
|
||||
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderRecord;
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.completed.InAppPaymentsBottomSheetDelegate;
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.UnexpectedSubscriptionCancellation;
|
||||
import org.thoughtcrime.securesms.components.snackbars.SnackbarState;
|
||||
import org.thoughtcrime.securesms.components.spoiler.SpoilerAnnotation;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNotePlayerView;
|
||||
@@ -134,10 +135,10 @@ import org.thoughtcrime.securesms.keyvalue.AccountValues;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.main.MainNavigationListLocation;
|
||||
import org.thoughtcrime.securesms.main.MainNavigationViewModel;
|
||||
import org.thoughtcrime.securesms.main.MainSnackbarHostKey;
|
||||
import org.thoughtcrime.securesms.main.MainToolbarMode;
|
||||
import org.thoughtcrime.securesms.main.MainToolbarViewModel;
|
||||
import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder;
|
||||
import org.thoughtcrime.securesms.main.SnackbarState;
|
||||
import org.thoughtcrime.securesms.megaphone.Megaphones;
|
||||
import org.thoughtcrime.securesms.notifications.MarkReadReceiver;
|
||||
import org.thoughtcrime.securesms.profiles.manage.UsernameEditFragment;
|
||||
@@ -979,7 +980,7 @@ public class ConversationListFragment extends MainFragment implements Conversati
|
||||
.subscribe(() -> {
|
||||
endActionModeIfActive();
|
||||
|
||||
mainNavigationViewModel.setSnackbar(new SnackbarState(
|
||||
mainNavigationViewModel.getSnackbarRegistry().emit(new SnackbarState(
|
||||
snackBarTitle,
|
||||
new SnackbarState.ActionState(
|
||||
getString(R.string.ConversationListFragment_undo),
|
||||
@@ -990,7 +991,9 @@ public class ConversationListFragment extends MainFragment implements Conversati
|
||||
}
|
||||
),
|
||||
showProgress,
|
||||
SnackbarDuration.Long
|
||||
SnackbarDuration.Long,
|
||||
MainSnackbarHostKey.MainChrome.INSTANCE,
|
||||
null
|
||||
));
|
||||
}));
|
||||
}
|
||||
@@ -1068,11 +1071,13 @@ public class ConversationListFragment extends MainFragment implements Conversati
|
||||
.toList());
|
||||
|
||||
if (toPin.size() + viewModel.getPinnedCount() > MAXIMUM_PINNED_CONVERSATIONS) {
|
||||
mainNavigationViewModel.setSnackbar(new SnackbarState(
|
||||
mainNavigationViewModel.getSnackbarRegistry().emit(new SnackbarState(
|
||||
getString(R.string.conversation_list__you_can_only_pin_up_to_d_chats, MAXIMUM_PINNED_CONVERSATIONS),
|
||||
null,
|
||||
false,
|
||||
SnackbarDuration.Long
|
||||
SnackbarDuration.Long,
|
||||
MainSnackbarHostKey.MainChrome.INSTANCE,
|
||||
null
|
||||
));
|
||||
|
||||
endActionModeIfActive();
|
||||
@@ -1419,7 +1424,7 @@ public class ConversationListFragment extends MainFragment implements Conversati
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(pinnedThreadIds -> {
|
||||
mainNavigationViewModel.setSnackbar(new SnackbarState(
|
||||
mainNavigationViewModel.getSnackbarRegistry().emit(new SnackbarState(
|
||||
getResources().getQuantityString(R.plurals.ConversationListFragment_conversations_archived, 1, 1),
|
||||
new SnackbarState.ActionState(
|
||||
getString(R.string.ConversationListFragment_undo),
|
||||
@@ -1436,7 +1441,9 @@ public class ConversationListFragment extends MainFragment implements Conversati
|
||||
}
|
||||
),
|
||||
false,
|
||||
SnackbarDuration.Long
|
||||
SnackbarDuration.Long,
|
||||
MainSnackbarHostKey.MainChrome.INSTANCE,
|
||||
null
|
||||
));
|
||||
})
|
||||
);
|
||||
|
||||
@@ -5,19 +5,18 @@
|
||||
|
||||
package org.thoughtcrime.securesms.main
|
||||
|
||||
import androidx.annotation.ColorRes
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -25,26 +24,15 @@ import org.signal.core.ui.compose.AllDevicePreviews
|
||||
import org.signal.core.ui.compose.Dialogs
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Snackbars
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.snackbars.SnackbarHostKey
|
||||
import org.thoughtcrime.securesms.components.snackbars.SnackbarState
|
||||
import org.thoughtcrime.securesms.components.snackbars.rememberSnackbarState
|
||||
import org.thoughtcrime.securesms.megaphone.Megaphone
|
||||
import org.thoughtcrime.securesms.megaphone.MegaphoneActionController
|
||||
import org.thoughtcrime.securesms.megaphone.Megaphones
|
||||
import org.thoughtcrime.securesms.window.NavigationType
|
||||
import org.thoughtcrime.securesms.window.isSplitPane
|
||||
|
||||
data class SnackbarState(
|
||||
val message: String,
|
||||
val actionState: ActionState? = null,
|
||||
val showProgress: Boolean = false,
|
||||
val duration: SnackbarDuration = SnackbarDuration.Long
|
||||
) {
|
||||
data class ActionState(
|
||||
val action: String,
|
||||
@ColorRes val color: Int = R.color.core_white,
|
||||
val onActionClick: () -> Unit
|
||||
)
|
||||
}
|
||||
|
||||
interface MainBottomChromeCallback : MainFloatingActionButtonsCallback {
|
||||
fun onMegaphoneVisible(megaphone: Megaphone)
|
||||
fun onSnackbarDismissed()
|
||||
@@ -61,7 +49,6 @@ interface MainBottomChromeCallback : MainFloatingActionButtonsCallback {
|
||||
data class MainBottomChromeState(
|
||||
val destination: MainNavigationListLocation = MainNavigationListLocation.CHATS,
|
||||
val megaphoneState: MainMegaphoneState = MainMegaphoneState(),
|
||||
val snackbarState: SnackbarState? = null,
|
||||
val mainToolbarMode: MainToolbarMode = MainToolbarMode.FULL
|
||||
)
|
||||
|
||||
@@ -117,7 +104,6 @@ fun MainBottomChrome(
|
||||
}
|
||||
|
||||
MainSnackbar(
|
||||
snackbarState = state.snackbarState,
|
||||
onDismissed = callback::onSnackbarDismissed,
|
||||
modifier = snackBarModifier
|
||||
)
|
||||
@@ -126,11 +112,13 @@ fun MainBottomChrome(
|
||||
|
||||
@Composable
|
||||
fun MainSnackbar(
|
||||
snackbarState: SnackbarState?,
|
||||
onDismissed: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
hostKey: SnackbarHostKey = MainSnackbarHostKey.MainChrome
|
||||
) {
|
||||
val hostState = remember { SnackbarHostState() }
|
||||
val state: SnackbarState? by rememberSnackbarState(hostKey)
|
||||
val snackbarState = state
|
||||
|
||||
Snackbars.Host(
|
||||
hostState,
|
||||
@@ -175,13 +163,6 @@ fun MainBottomChromePreview() {
|
||||
state = MainBottomChromeState(
|
||||
megaphoneState = MainMegaphoneState(
|
||||
megaphone = megaphone
|
||||
),
|
||||
snackbarState = SnackbarState(
|
||||
message = "Test Message",
|
||||
actionState = SnackbarState.ActionState(
|
||||
action = "Ok",
|
||||
onActionClick = {}
|
||||
)
|
||||
)
|
||||
),
|
||||
callback = MainBottomChromeCallback.Empty,
|
||||
|
||||
@@ -27,6 +27,7 @@ import kotlinx.coroutines.reactive.asFlow
|
||||
import kotlinx.coroutines.rx3.asObservable
|
||||
import org.thoughtcrime.securesms.calls.log.CallLogRow
|
||||
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.NotificationProfilesRepository
|
||||
import org.thoughtcrime.securesms.components.snackbars.SnackbarStateConsumerRegistry
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.megaphone.Megaphone
|
||||
@@ -65,9 +66,6 @@ class MainNavigationViewModel(
|
||||
private val internalMegaphone = MutableStateFlow(Megaphone.NONE)
|
||||
val megaphone: StateFlow<Megaphone> = internalMegaphone
|
||||
|
||||
private val internalSnackbar = MutableStateFlow<SnackbarState?>(null)
|
||||
val snackbar: StateFlow<SnackbarState?> = internalSnackbar
|
||||
|
||||
private val internalNavigationEvents = MutableSharedFlow<NavigationEvent>()
|
||||
val navigationEvents: Flow<NavigationEvent> = internalNavigationEvents
|
||||
|
||||
@@ -98,6 +96,8 @@ class MainNavigationViewModel(
|
||||
*/
|
||||
private var lockPaneToSecondary = false
|
||||
|
||||
val snackbarRegistry = SnackbarStateConsumerRegistry()
|
||||
|
||||
init {
|
||||
performStoreUpdate(MainNavigationRepository.getNumberOfUnreadMessages()) { unreadChats, state ->
|
||||
state.copy(chatsCount = unreadChats.toInt())
|
||||
@@ -232,10 +232,6 @@ class MainNavigationViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun setSnackbar(snackbarState: SnackbarState?) {
|
||||
internalSnackbar.update { snackbarState }
|
||||
}
|
||||
|
||||
fun onMegaphoneSnoozed(event: Megaphones.Event) {
|
||||
megaphoneRepository.markSeen(event)
|
||||
internalMegaphone.update { Megaphone.NONE }
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.main
|
||||
|
||||
import org.thoughtcrime.securesms.components.snackbars.SnackbarHostKey
|
||||
|
||||
sealed interface MainSnackbarHostKey : SnackbarHostKey {
|
||||
data object Chat : MainSnackbarHostKey
|
||||
data object MainChrome : MainSnackbarHostKey
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.components.snackbars.SnackbarState
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs
|
||||
@@ -32,10 +33,10 @@ import org.thoughtcrime.securesms.database.model.StoryViewState
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.main.MainNavigationListLocation
|
||||
import org.thoughtcrime.securesms.main.MainNavigationViewModel
|
||||
import org.thoughtcrime.securesms.main.MainSnackbarHostKey
|
||||
import org.thoughtcrime.securesms.main.MainToolbarMode
|
||||
import org.thoughtcrime.securesms.main.MainToolbarViewModel
|
||||
import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder
|
||||
import org.thoughtcrime.securesms.main.SnackbarState
|
||||
import org.thoughtcrime.securesms.permissions.Permissions
|
||||
import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet
|
||||
import org.thoughtcrime.securesms.stories.StoryTextPostModel
|
||||
@@ -332,9 +333,10 @@ class StoriesLandingFragment : DSLSettingsFragment(layoutId = R.layout.stories_l
|
||||
private fun handleHideStory(model: StoriesLandingItem.Model) {
|
||||
StoryDialogs.hideStory(requireContext(), model.data.storyRecipient.getShortDisplayName(requireContext())) {
|
||||
viewModel.setHideStory(model.data.storyRecipient, true).subscribe {
|
||||
mainNavigationViewModel.setSnackbar(
|
||||
mainNavigationViewModel.snackbarRegistry.emit(
|
||||
SnackbarState(
|
||||
message = getString(R.string.StoriesLandingFragment__story_hidden)
|
||||
message = getString(R.string.StoriesLandingFragment__story_hidden),
|
||||
hostKey = MainSnackbarHostKey.MainChrome
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user