From af918cf69d7358083f804c39c74813d28cff3b80 Mon Sep 17 00:00:00 2001 From: 3r01 <203520021+3r01@users.noreply.github.com> Date: Sun, 30 Aug 2026 22:47:25 +0100 Subject: [PATCH] feat: mute highlight notifications by channel --- .../data/notification/NotificationService.kt | 14 ++++++---- .../notifications/NotificationsSettings.kt | 20 ++++++++++++- .../NotificationsSettingsDataStore.kt | 8 ++++++ .../flxrs/dankchat/ui/main/FloatingToolbar.kt | 2 ++ .../com/flxrs/dankchat/ui/main/MainAppBar.kt | 23 ++++++++++++++- .../com/flxrs/dankchat/ui/main/MainScreen.kt | 9 ++++++ .../flxrs/dankchat/ui/main/ToolbarAction.kt | 2 ++ .../channel/ChannelManagementViewModel.kt | 19 +++++++++++++ .../ui/main/dialog/MainScreenDialogs.kt | 3 ++ .../ui/main/dialog/ManageChannelsDialog.kt | 28 +++++++++++++++++++ app/src/main/res/values/strings.xml | 4 +++ .../NotificationsSettingsTest.kt | 19 +++++++++++++ 12 files changed, 144 insertions(+), 7 deletions(-) create mode 100644 app/src/test/kotlin/com/flxrs/dankchat/preferences/notifications/NotificationsSettingsTest.kt diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/notification/NotificationService.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/notification/NotificationService.kt index f46cfc446..2aa96d5ce 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/notification/NotificationService.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/notification/NotificationService.kt @@ -104,12 +104,12 @@ class NotificationService : AppLifecycle.Background -> { combine( chatNotificationRepository.messageUpdates, - notificationsSettingsDataStore.showNotifications, - ) { items, enabled -> items to enabled } + notificationsSettingsDataStore.settings, + ) { items, settings -> items to settings } } } - }.collect { (items, enabled) -> - if (!enabled) { + }.collect { (items, settings) -> + if (!settings.showNotifications) { return@collect } @@ -122,7 +122,11 @@ class NotificationService : iterator.next() iterator.remove() } - message.toNotificationData()?.createMentionNotification() + val notificationData = message.toNotificationData() ?: return@forEach + if (!notificationData.isWhisper && !settings.areChannelNotificationsEnabled(notificationData.channel)) { + return@forEach + } + notificationData.createMentionNotification() } } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/NotificationsSettings.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/NotificationsSettings.kt index 3c8e0b158..32c44daaf 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/NotificationsSettings.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/NotificationsSettings.kt @@ -1,5 +1,6 @@ package com.flxrs.dankchat.preferences.notifications +import com.flxrs.dankchat.data.UserName import kotlinx.serialization.Serializable @Serializable @@ -7,7 +8,24 @@ data class NotificationsSettings( val showNotifications: Boolean = true, val showWhisperNotifications: Boolean = true, val mentionFormat: MentionFormat = MentionFormat.Name, -) + val mutedChannels: Set = emptySet(), +) { + fun areChannelNotificationsEnabled(channel: UserName): Boolean = channel.lowercase() !in mutedChannels + + fun withChannelNotificationsEnabled( + channel: UserName, + enabled: Boolean, + ): NotificationsSettings { + val normalizedChannel = channel.lowercase() + return copy( + mutedChannels = + when { + enabled -> mutedChannels - normalizedChannel + else -> mutedChannels + normalizedChannel + }, + ) + } +} enum class MentionFormat( val template: String, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/NotificationsSettingsDataStore.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/NotificationsSettingsDataStore.kt index f9078636d..9f4825128 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/NotificationsSettingsDataStore.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/NotificationsSettingsDataStore.kt @@ -2,6 +2,7 @@ package com.flxrs.dankchat.preferences.notifications import android.content.Context import com.flxrs.dankchat.R +import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.di.DispatchersProvider import com.flxrs.dankchat.utils.datastore.PreferenceKeys import com.flxrs.dankchat.utils.datastore.booleanOrDefault @@ -78,6 +79,13 @@ class NotificationsSettingsDataStore( fun current() = currentSettings.value + suspend fun setChannelNotificationsEnabled( + channel: UserName, + enabled: Boolean, + ) { + update { it.withChannelNotificationsEnabled(channel, enabled) } + } + suspend fun update(transform: suspend (NotificationsSettings) -> NotificationsSettings) { runCatching { dataStore.updateData(transform) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt index fdb8f8c98..43e3b859a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt @@ -140,6 +140,7 @@ fun FloatingToolbar( totalMentionCount: Int, hasActivePinnedMessage: Boolean, isPinnedMessageShown: Boolean, + channelNotificationsEnabled: Boolean, onAction: (ToolbarAction) -> Unit, onAudioOnly: () -> Unit, onStreamClose: () -> Unit, @@ -797,6 +798,7 @@ fun FloatingToolbar( ) { InlineOverflowMenu( isLoggedIn = isLoggedIn, + channelNotificationsEnabled = channelNotificationsEnabled, onDismiss = { showOverflowMenu = false overflowInitialMenu = AppBarMenu.Main diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainAppBar.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainAppBar.kt index 533c5dd9f..00c15a487 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainAppBar.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainAppBar.kt @@ -39,6 +39,8 @@ import androidx.compose.material.icons.filled.EmojiEmotions import androidx.compose.material.icons.filled.Flag import androidx.compose.material.icons.filled.Image import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.NotificationsOff import androidx.compose.material.icons.filled.OpenInBrowser import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.RemoveCircleOutline @@ -134,6 +136,7 @@ internal val LocalInlineMenuItemRegistry = staticCompositionLocalOf Unit, onAction: (ToolbarAction) -> Unit, initialMenu: AppBarMenu = AppBarMenu.Main, @@ -231,6 +234,7 @@ fun InlineOverflowMenu( AppBarMenu.Channel -> ChannelMenuContent( isLoggedIn = isLoggedIn, + notificationsEnabled = channelNotificationsEnabled, onAction = onAction, onDismiss = onDismiss, onBack = { currentMenu = AppBarMenu.Main }, @@ -485,12 +489,30 @@ private fun ColumnScope.UploadMenuContent( @Composable private fun ColumnScope.ChannelMenuContent( isLoggedIn: Boolean, + notificationsEnabled: Boolean, onAction: (ToolbarAction) -> Unit, onDismiss: () -> Unit, onBack: () -> Unit, modifier: Modifier = Modifier, ) { InlineSubMenuHeader(title = stringResource(R.string.channel), onBack = onBack) + InlineMenuItem( + text = + stringResource( + when { + notificationsEnabled -> R.string.channel_highlight_notifications_on + else -> R.string.channel_highlight_notifications_off + }, + ), + icon = + when { + notificationsEnabled -> Icons.Default.Notifications + else -> Icons.Default.NotificationsOff + }, + onClick = { onAction(ToolbarAction.ToggleChannelNotifications) }, + modifier = modifier, + maxLines = 2, + ) InlineMenuItem( text = stringResource(R.string.open_channel), icon = Icons.Default.OpenInBrowser, @@ -498,7 +520,6 @@ private fun ColumnScope.ChannelMenuContent( onAction(ToolbarAction.OpenChannel) onDismiss() }, - modifier = modifier, maxLines = 2, ) InlineMenuItem( diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt index e259cb13e..2e9813b23 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt @@ -291,6 +291,8 @@ fun MainScreen( val tabState = channelTabViewModel.uiState.collectAsStateWithLifecycle().value val activeChannel = tabState.tabs.getOrNull(tabState.selectedIndex)?.channel + val mutedNotificationChannels by channelManagementViewModel.mutedNotificationChannels.collectAsStateWithLifecycle() + val channelNotificationsEnabled = activeChannel == null || activeChannel.lowercase() !in mutedNotificationChannels // Same key as in ChatComposable, so this resolves the active page's instance val activePinnedMessageViewModel = @@ -673,6 +675,12 @@ fun MainScreen( dialogViewModel.showBlockChannel() } + ToolbarAction.ToggleChannelNotifications -> { + activeChannel?.let { + channelManagementViewModel.setChannelNotificationsEnabled(it, !channelNotificationsEnabled) + } + } + ToolbarAction.CaptureImage -> { if (preferenceStore.hasExternalHostingAcknowledged) onCaptureImage() else dialogViewModel.setPendingUploadAction(onCaptureImage) } @@ -718,6 +726,7 @@ fun MainScreen( totalMentionCount = tabState.tabs.sumOf { it.mentionCount } + tabState.whisperMentionCount, hasActivePinnedMessage = activePinnedMessageState != PinnedMessageUiState.Hidden, isPinnedMessageShown = activePinnedMessageState is PinnedMessageUiState.Expanded, + channelNotificationsEnabled = channelNotificationsEnabled, onAction = handleToolbarAction, onAudioOnly = { streamViewModel.toggleAudioOnly() }, onStreamClose = { streamViewModel.closeStream() }, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/ToolbarAction.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/ToolbarAction.kt index 10021cd0d..86a73f09b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/ToolbarAction.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/ToolbarAction.kt @@ -30,6 +30,8 @@ sealed interface ToolbarAction { data object BlockChannel : ToolbarAction + data object ToggleChannelNotifications : ToolbarAction + data object CaptureImage : ToolbarAction data object CaptureVideo : ToolbarAction diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/channel/ChannelManagementViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/channel/ChannelManagementViewModel.kt index 21c1fc94a..9023bb645 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/channel/ChannelManagementViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/channel/ChannelManagementViewModel.kt @@ -13,9 +13,13 @@ import com.flxrs.dankchat.data.repo.chat.ChatRepository import com.flxrs.dankchat.domain.ChannelDataCoordinator import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.preferences.model.ChannelWithRename +import com.flxrs.dankchat.preferences.notifications.NotificationsSettingsDataStore import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map @@ -33,6 +37,7 @@ class ChannelManagementViewModel( private val chatNotificationRepository: ChatNotificationRepository, private val ignoresRepository: IgnoresRepository, private val channelRepository: ChannelRepository, + private val notificationsSettingsDataStore: NotificationsSettingsDataStore, channelSelectionDataStore: ChannelSelectionDataStore, ) : ViewModel() { val channels: StateFlow> = @@ -41,6 +46,11 @@ class ChannelManagementViewModel( .map { it.toImmutableList() } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), persistentListOf()) + val mutedNotificationChannels: StateFlow> = + notificationsSettingsDataStore.settings + .map { it.mutedChannels.toImmutableSet() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), persistentSetOf()) + init { // Restore persisted channel selection, falling back to first channel viewModelScope.launch { @@ -118,6 +128,15 @@ class ChannelManagementViewModel( chatConnector.reconnect() } + fun setChannelNotificationsEnabled( + channel: UserName, + enabled: Boolean, + ) { + viewModelScope.launch { + notificationsSettingsDataStore.setChannelNotificationsEnabled(channel, enabled) + } + } + fun blockChannel(channel: UserName) = viewModelScope.launch { runCatching { if (!preferenceStore.isLoggedIn) { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/MainScreenDialogs.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/MainScreenDialogs.kt index a06b43180..73211dcf7 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/MainScreenDialogs.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/MainScreenDialogs.kt @@ -106,10 +106,13 @@ fun MainScreenDialogs( if (dialogState.showManageChannels) { val channels by channelManagementViewModel.channels.collectAsStateWithLifecycle() + val mutedNotificationChannels by channelManagementViewModel.mutedNotificationChannels.collectAsStateWithLifecycle() ManageChannelsDialog( channels = channels, + mutedNotificationChannels = mutedNotificationChannels, onApplyChanges = channelManagementViewModel::applyChanges, onChannelSelect = channelManagementViewModel::selectChannel, + onChannelNotificationsChange = channelManagementViewModel::setChannelNotificationsEnabled, onDismiss = dialogViewModel::dismissManageChannels, ) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/ManageChannelsDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/ManageChannelsDialog.kt index 22825148c..a46010182 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/ManageChannelsDialog.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/ManageChannelsDialog.kt @@ -27,6 +27,8 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.NotificationsOff import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api @@ -74,8 +76,10 @@ import sh.calvin.reorderable.rememberReorderableLazyListState @Composable fun ManageChannelsDialog( channels: List, + mutedNotificationChannels: Set, onApplyChanges: (List) -> Unit, onChannelSelect: (UserName) -> Unit, + onChannelNotificationsChange: (UserName, Boolean) -> Unit, onDismiss: () -> Unit, ) { var channelToDelete by remember { mutableStateOf(null) } @@ -138,6 +142,7 @@ fun ManageChannelsDialog( ChannelItem( channelWithRename = channelWithRename, isEditing = editingChannel == channelWithRename.channel, + notificationsEnabled = channelWithRename.channel.lowercase() !in mutedNotificationChannels, modifier = Modifier.longPressDraggableHandle( onDragStarted = { /* Optional haptic feedback here */ }, @@ -161,6 +166,9 @@ fun ManageChannelsDialog( editingChannel = null }, onDelete = { channelToDelete = channelWithRename.channel }, + onNotificationsChange = { enabled -> + onChannelNotificationsChange(channelWithRename.channel, enabled) + }, ) if (index < localChannels.lastIndex) { HorizontalDivider( @@ -205,10 +213,12 @@ fun ManageChannelsDialog( private fun ChannelItem( channelWithRename: ChannelWithRename, isEditing: Boolean, + notificationsEnabled: Boolean, onNavigate: () -> Unit, onEdit: () -> Unit, onRename: (String?) -> Unit, onDelete: () -> Unit, + onNotificationsChange: (Boolean) -> Unit, modifier: Modifier = Modifier, ) { Column(modifier = modifier) { @@ -244,6 +254,24 @@ private fun ChannelItem( .padding(horizontal = 8.dp), ) + IconButton(onClick = { onNotificationsChange(!notificationsEnabled) }) { + Icon( + imageVector = + when { + notificationsEnabled -> Icons.Default.Notifications + else -> Icons.Default.NotificationsOff + }, + contentDescription = + stringResource( + when { + notificationsEnabled -> R.string.disable_channel_notifications + else -> R.string.enable_channel_notifications + }, + channelWithRename.channel.value, + ), + ) + } + IconButton(onClick = onNavigate) { Icon( imageVector = Icons.AutoMirrored.Filled.OpenInNew, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ada1dcd54..c0fc293ef 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -25,6 +25,10 @@ Channel blocked No channels added Add a channel to start chatting + Enable highlight notifications for %1$s + Disable highlight notifications for %1$s + Highlight notifications on + Highlight notifications off Confirm logout Are you sure you want to logout? Log out? diff --git a/app/src/test/kotlin/com/flxrs/dankchat/preferences/notifications/NotificationsSettingsTest.kt b/app/src/test/kotlin/com/flxrs/dankchat/preferences/notifications/NotificationsSettingsTest.kt new file mode 100644 index 000000000..1a439a099 --- /dev/null +++ b/app/src/test/kotlin/com/flxrs/dankchat/preferences/notifications/NotificationsSettingsTest.kt @@ -0,0 +1,19 @@ +package com.flxrs.dankchat.preferences.notifications + +import com.flxrs.dankchat.data.toUserName +import org.junit.jupiter.api.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +internal class NotificationsSettingsTest { + @Test + fun `channel notifications can be disabled and enabled`() { + val disabled = NotificationsSettings().withChannelNotificationsEnabled("forsen".toUserName(), enabled = false) + + assertFalse(disabled.areChannelNotificationsEnabled("forsen".toUserName())) + assertTrue(disabled.areChannelNotificationsEnabled("iore".toUserName())) + + val enabled = disabled.withChannelNotificationsEnabled("forsen".toUserName(), enabled = true) + assertTrue(enabled.areChannelNotificationsEnabled("forsen".toUserName())) + } +}