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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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