From 0660771dd3b942fc51c7ef1d564846469894a415 Mon Sep 17 00:00:00 2001 From: Infi Date: Sun, 2 Feb 2025 03:34:35 +0100 Subject: [PATCH] feat: muted channels and servers this also adds a handler for malformed settings because this is basically required for parsing notifications Signed-off-by: Infi --- .../revolt/api/internals/DirectMessages.kt | 12 +- .../java/chat/revolt/api/schemas/Settings.kt | 14 +++ .../revolt/api/settings/LoadedSettings.kt | 2 + .../settings/NotificationSettingsProvider.kt | 12 ++ .../revolt/api/settings/SyncedSettings.kt | 109 ++++++++++++++++-- .../java/chat/revolt/api/unreads/Unreads.kt | 21 ++-- .../screens/chat/drawer/ChannelSideDrawer.kt | 34 +++++- .../screens/settings/ChatSettingsScreen.kt | 77 ++++++++++++- app/src/main/res/values/strings.xml | 7 ++ 9 files changed, 265 insertions(+), 23 deletions(-) create mode 100644 app/src/main/java/chat/revolt/api/settings/NotificationSettingsProvider.kt diff --git a/app/src/main/java/chat/revolt/api/internals/DirectMessages.kt b/app/src/main/java/chat/revolt/api/internals/DirectMessages.kt index ad9bda11..a0d3009a 100644 --- a/app/src/main/java/chat/revolt/api/internals/DirectMessages.kt +++ b/app/src/main/java/chat/revolt/api/internals/DirectMessages.kt @@ -14,21 +14,27 @@ object DirectMessages { ) && it.active == true && it.lastMessageID != null } .filter { - it.id?.let { id -> RevoltAPI.unreads.hasUnread(id, it.lastMessageID!!) } ?: false + it.id?.let { id -> + RevoltAPI.unreads.hasUnread( + id, + it.lastMessageID!!, + serverId = null + ) + } ?: false } } fun hasPlatformModerationDM(): Boolean { return unreadDMs().any { it.channelType == ChannelType.DirectMessage && - it.recipients?.contains(PLATFORM_MODERATION_USER) ?: false + it.recipients?.contains(PLATFORM_MODERATION_USER) ?: false } } fun getPlatformModerationDM(): Channel? { return unreadDMs().firstOrNull { it.channelType == ChannelType.DirectMessage && - it.recipients?.contains(PLATFORM_MODERATION_USER) ?: false + it.recipients?.contains(PLATFORM_MODERATION_USER) ?: false } } } diff --git a/app/src/main/java/chat/revolt/api/schemas/Settings.kt b/app/src/main/java/chat/revolt/api/schemas/Settings.kt index a4a46d00..2577a6c0 100644 --- a/app/src/main/java/chat/revolt/api/schemas/Settings.kt +++ b/app/src/main/java/chat/revolt/api/schemas/Settings.kt @@ -2,12 +2,26 @@ package chat.revolt.api.schemas import chat.revolt.ui.theme.OverridableColourScheme import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement @Serializable data class OrderingSettings( val servers: List = emptyList() ) + +@Serializable +data class NotificationSettings( + val channel: Map = emptyMap(), + val server: Map = emptyMap() +) + +@Serializable +data class _NotificationSettingsToParse( // quirk + val channel: Map = emptyMap(), + val server: Map = emptyMap() +) + @Serializable data class AndroidSpecificSettingsSpecialEmbedSettings( /** diff --git a/app/src/main/java/chat/revolt/api/settings/LoadedSettings.kt b/app/src/main/java/chat/revolt/api/settings/LoadedSettings.kt index c3a10db4..ea322c00 100644 --- a/app/src/main/java/chat/revolt/api/settings/LoadedSettings.kt +++ b/app/src/main/java/chat/revolt/api/settings/LoadedSettings.kt @@ -22,6 +22,7 @@ object LoadedSettings { var avatarRadius by mutableIntStateOf(50) var experimentsEnabled by mutableStateOf(false) var specialEmbedSettings by mutableStateOf(SpecialEmbedSettings()) + var poorlyFormedSettingsKeys by mutableStateOf(emptySet()) fun hydrateWithSettings(settings: SyncedSettings) { this.theme = settings.android.theme?.let { Theme.valueOf(it) } ?: getDefaultTheme() @@ -37,5 +38,6 @@ object LoadedSettings { messageReplyStyle = MessageReplyStyle.SwipeFromEnd avatarRadius = 50 specialEmbedSettings = SpecialEmbedSettings() + poorlyFormedSettingsKeys = emptySet() } } diff --git a/app/src/main/java/chat/revolt/api/settings/NotificationSettingsProvider.kt b/app/src/main/java/chat/revolt/api/settings/NotificationSettingsProvider.kt new file mode 100644 index 00000000..899b3913 --- /dev/null +++ b/app/src/main/java/chat/revolt/api/settings/NotificationSettingsProvider.kt @@ -0,0 +1,12 @@ +package chat.revolt.api.settings + +object NotificationSettingsProvider { + fun isChannelMuted(channelId: String, serverId: String?): Boolean { + if (serverId != null) { + // When the server is muted, all channels are muted + if (SyncedSettings.notifications.server[serverId] == "muted") return true + } + + return SyncedSettings.notifications.channel[channelId] == "muted" + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/api/settings/SyncedSettings.kt b/app/src/main/java/chat/revolt/api/settings/SyncedSettings.kt index 3727ff5f..3bbe4b53 100644 --- a/app/src/main/java/chat/revolt/api/settings/SyncedSettings.kt +++ b/app/src/main/java/chat/revolt/api/settings/SyncedSettings.kt @@ -6,7 +6,21 @@ import chat.revolt.api.RevoltJson import chat.revolt.api.routes.sync.getKeys import chat.revolt.api.routes.sync.setKey import chat.revolt.api.schemas.AndroidSpecificSettings +import chat.revolt.api.schemas.NotificationSettings import chat.revolt.api.schemas.OrderingSettings +import chat.revolt.api.schemas._NotificationSettingsToParse +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonPrimitive +import logcat.LogPriority +import logcat.asLog +import logcat.logcat + +/* + * - Note: When adding a new key - + * 1. Add corresponding methods and fields here + * 2. Add strings for poorly formed keys hint + * 3. Add UI for resetting the key if it's poorly formed + */ object SyncedSettings { private val _ordering = mutableStateOf(OrderingSettings()) @@ -17,34 +31,81 @@ object SyncedSettings { messageReplyStyle = "None" ) ) + private val _notifications = mutableStateOf(NotificationSettings()) val ordering: OrderingSettings get() = _ordering.value val android: AndroidSpecificSettings get() = _android.value + val notifications: NotificationSettings + get() = _notifications.value suspend fun fetch(revoltToken: String = RevoltAPI.sessionToken) { try { - val settings = getKeys("ordering", "android", revoltToken = revoltToken) + val settings = + getKeys("ordering", "android", "notifications", revoltToken = revoltToken) settings["ordering"]?.let { - _ordering.value = RevoltJson.decodeFromString( - OrderingSettings.serializer(), - it.value - ) + try { + _ordering.value = RevoltJson.decodeFromString( + OrderingSettings.serializer(), + it.value + ) + } catch (e: Exception) { + LoadedSettings.poorlyFormedSettingsKeys += "ordering" + e.printStackTrace() + } } settings["android"]?.let { - _android.value = RevoltJson.decodeFromString( - AndroidSpecificSettings.serializer(), - it.value - ) + try { + _android.value = RevoltJson.decodeFromString( + AndroidSpecificSettings.serializer(), + it.value + ) + } catch (e: Exception) { + LoadedSettings.poorlyFormedSettingsKeys += "android" + e.printStackTrace() + } + } + + settings["notifications"]?.let { + // This is to fix a quirk where the web client sometimes leaves sub-objects in one of the objects + // Because it is written in typescript and does what it wants + _notifications.value = parseNotificationSettings(it.value) } } catch (e: Exception) { e.printStackTrace() } } + private fun parseNotificationSettings(value: String): NotificationSettings { + return try { + var intermediate = + RevoltJson.decodeFromString(_NotificationSettingsToParse.serializer(), value) + + // Throw out any value of intermediate.server and .channel that isn't a string + intermediate = intermediate.copy( + server = intermediate.server.filterValues { it != null } + .filterValues { it is JsonPrimitive } + .filterValues { it!!.jsonPrimitive.isString }, + channel = intermediate.channel.filterValues { it != null } + .filterValues { it is JsonPrimitive } + .filterValues { it!!.jsonPrimitive.isString } + ) + + // Convert the intermediate to a NotificationSettings + NotificationSettings( + server = intermediate.server.mapValues { it.value!!.jsonPrimitive.content }, + channel = intermediate.channel.mapValues { it.value!!.jsonPrimitive.content } + ) + } catch (e: Exception) { + logcat(LogPriority.ERROR) { e.asLog() } + LoadedSettings.poorlyFormedSettingsKeys += "notifications" + NotificationSettings() + } + } + suspend fun updateOrdering(value: OrderingSettings) { _ordering.value = value setKey("ordering", RevoltJson.encodeToString(OrderingSettings.serializer(), value)) @@ -54,4 +115,34 @@ object SyncedSettings { _android.value = value setKey("android", RevoltJson.encodeToString(AndroidSpecificSettings.serializer(), value)) } + + suspend fun updateNotifications(value: NotificationSettings) { + _notifications.value = value + setKey("notifications", RevoltJson.encodeToString(NotificationSettings.serializer(), value)) + } + + suspend fun resetOrdering() { + val default = OrderingSettings() + _ordering.value = default + setKey("ordering", RevoltJson.encodeToString(OrderingSettings.serializer(), default)) + } + + suspend fun resetAndroid() { + val default = AndroidSpecificSettings( + theme = "None", + colourOverrides = null, + messageReplyStyle = "None" + ) + _android.value = default + setKey("android", RevoltJson.encodeToString(AndroidSpecificSettings.serializer(), default)) + } + + suspend fun resetNotifications() { + val default = NotificationSettings() + _notifications.value = default + setKey( + "notifications", + RevoltJson.encodeToString(NotificationSettings.serializer(), default) + ) + } } diff --git a/app/src/main/java/chat/revolt/api/unreads/Unreads.kt b/app/src/main/java/chat/revolt/api/unreads/Unreads.kt index f93dc8a3..4be01c41 100644 --- a/app/src/main/java/chat/revolt/api/unreads/Unreads.kt +++ b/app/src/main/java/chat/revolt/api/unreads/Unreads.kt @@ -10,6 +10,7 @@ import chat.revolt.api.routes.server.ackServer import chat.revolt.api.routes.sync.syncUnreads import chat.revolt.api.schemas.ChannelType import chat.revolt.api.schemas.ChannelUnread +import chat.revolt.api.settings.NotificationSettingsProvider class Unreads { private val hasLoaded = mutableStateOf(false) @@ -34,13 +35,15 @@ class Unreads { hasLoaded.value = true } - fun getForChannel(channelId: String): ChannelUnread? { + fun getForChannel(channelId: String, serverId: String?): ChannelUnread? { if (!hasLoaded.value) return null + if (NotificationSettingsProvider.isChannelMuted(channelId, serverId)) return null return channels[channelId] } - fun hasUnread(channelId: String, lastMessageId: String): Boolean { + fun hasUnread(channelId: String, lastMessageId: String, serverId: String?): Boolean { if (!hasLoaded.value) return false + if (NotificationSettingsProvider.isChannelMuted(channelId, serverId)) return false return (channels[channelId]?.last_id?.compareTo(lastMessageId) ?: 0) < 0 } @@ -48,11 +51,15 @@ class Unreads { if (!hasLoaded.value) return false return RevoltAPI.serverCache[serverId]?.channels?.any { - val channel = RevoltAPI.channelCache[it] ?: return@any false - if (channel.channelType == ChannelType.VoiceChannel) return@any false // TODO remove this when text in voice channels is implemented - hasUnread(it, channel.lastMessageID ?: "") - } - ?: false + val channel = RevoltAPI.channelCache[it] ?: return@any false // Channel not found + if (channel.channelType == ChannelType.VoiceChannel) return@any false // Channel is voice + if (NotificationSettingsProvider.isChannelMuted( + it, + serverId + ) + ) return@any false // Channel is muted + hasUnread(it, channel.lastMessageID ?: "", serverId) // Channel has unread + } == true // Null guard } suspend fun markAsRead(channelId: String, messageId: String, sync: Boolean = true) { diff --git a/app/src/main/java/chat/revolt/components/screens/chat/drawer/ChannelSideDrawer.kt b/app/src/main/java/chat/revolt/components/screens/chat/drawer/ChannelSideDrawer.kt index 8560f814..24af6a25 100644 --- a/app/src/main/java/chat/revolt/components/screens/chat/drawer/ChannelSideDrawer.kt +++ b/app/src/main/java/chat/revolt/components/screens/chat/drawer/ChannelSideDrawer.kt @@ -87,6 +87,7 @@ import chat.revolt.api.schemas.ChannelType import chat.revolt.api.schemas.ServerFlags import chat.revolt.api.schemas.User import chat.revolt.api.schemas.has +import chat.revolt.api.settings.NotificationSettingsProvider import chat.revolt.api.settings.SyncedSettings import chat.revolt.components.generic.GroupIcon import chat.revolt.components.generic.IconPlaceholder @@ -552,7 +553,8 @@ fun ChannelSideDrawer( onDestinationChanged, drawerState, channelListState, - onOpenChannelContextSheet = { channelContextSheetTarget = it } + onOpenChannelContextSheet = { channelContextSheetTarget = it }, + serverId = currentServer ) } } @@ -687,9 +689,12 @@ fun ColumnScope.DirectMessagesChannelListRenderer( }, hasUnread = channel.lastMessageID?.let { lastMessageID -> RevoltAPI.unreads.hasUnread( - channel.id!!, lastMessageID + channel.id!!, + lastMessageID, + serverId = null ) } ?: false, + isMuted = NotificationSettingsProvider.isChannelMuted(channel.id!!, null), onDestinationChanged = { dest -> onDestinationChanged(dest) scope.launch { @@ -719,6 +724,7 @@ fun ColumnScope.ServerChannelListRenderer( drawerState: DrawerState?, channelListState: LazyListState, onOpenChannelContextSheet: (String) -> Unit, + serverId: String ) { val scope = rememberCoroutineScope() @@ -772,9 +778,15 @@ fun ColumnScope.ServerChannelListRenderer( }, hasUnread = channelOrCat.channel.lastMessageID?.let { lastMessageID -> RevoltAPI.unreads.hasUnread( - channelOrCat.channel.id!!, lastMessageID + channelOrCat.channel.id!!, + lastMessageID, + serverId ) } ?: false, + isMuted = NotificationSettingsProvider.isChannelMuted( + channelOrCat.channel.id!!, + serverId + ), onOpenChannelContextSheet = onOpenChannelContextSheet ) } @@ -812,6 +824,7 @@ fun ChannelItem( channel.channelType ?: ChannelType.TextChannel ), hasUnread: Boolean = false, + isMuted: Boolean = false, appendServerName: Boolean = false, onDestinationChanged: (ChatRouterDestination) -> Unit, onOpenChannelContextSheet: (String) -> Unit @@ -854,6 +867,13 @@ fun ChannelItem( Modifier } ) + .then( + if (isMuted) { + Modifier.alpha(0.5f) + } else { + Modifier + } + ) .padding(16.dp) .fillMaxWidth()) { when (iconType) { @@ -910,6 +930,7 @@ fun DMOrGroupItem( partner: User?, isCurrent: Boolean, hasUnread: Boolean, + isMuted: Boolean = false, onDestinationChanged: (ChatRouterDestination) -> Unit, onOpenChannelContextSheet: (String) -> Unit ) { @@ -941,6 +962,13 @@ fun DMOrGroupItem( ) .padding(vertical = 16.dp) .fillMaxWidth() + .then( + if (isMuted) { + Modifier.alpha(0.5f) + } else { + Modifier + } + ) ) { Box( Modifier diff --git a/app/src/main/java/chat/revolt/screens/settings/ChatSettingsScreen.kt b/app/src/main/java/chat/revolt/screens/settings/ChatSettingsScreen.kt index b0a30184..4d708e3b 100644 --- a/app/src/main/java/chat/revolt/screens/settings/ChatSettingsScreen.kt +++ b/app/src/main/java/chat/revolt/screens/settings/ChatSettingsScreen.kt @@ -1,7 +1,10 @@ package chat.revolt.screens.settings import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding @@ -10,16 +13,20 @@ import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource @@ -54,13 +61,14 @@ class ChatSettingsScreenViewModel : ViewModel() { } } -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun ChatSettingsScreen( navController: NavController, viewModel: ChatSettingsScreenViewModel = viewModel() ) { val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + val scope = rememberCoroutineScope() Scaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), @@ -95,6 +103,73 @@ fun ChatSettingsScreen( .fillMaxSize() .verticalScroll(scrollState) ) { + if (LoadedSettings.poorlyFormedSettingsKeys.isNotEmpty()) { + Card( + modifier = Modifier.padding(16.dp) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = stringResource(R.string.settings_chat_hint_poorly_formed_settings_keys), + style = MaterialTheme.typography.titleMedium + ) + Text( + text = stringResource(R.string.settings_chat_hint_poorly_formed_settings_keys_description), + modifier = Modifier.padding(top = 8.dp) + ) + for (key in LoadedSettings.poorlyFormedSettingsKeys) { + Text( + text = " • " + when (key) { + "ordering" -> stringResource(R.string.settings_chat_hint_poorly_formed_settings_keys_key_ordering) + "android" -> stringResource(R.string.settings_chat_hint_poorly_formed_settings_keys_key_android) + "notifications" -> stringResource(R.string.settings_chat_hint_poorly_formed_settings_keys_key_notifications) + else -> stringResource( + R.string.settings_chat_hint_poorly_formed_settings_keys_key_unknown, + key + ) + }, + modifier = Modifier.padding(top = 8.dp) + ) + } + FlowRow( + modifier = Modifier.padding(top = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + for (key in LoadedSettings.poorlyFormedSettingsKeys.filter { + it in setOf("ordering", "android", "notifications") + }) { + TextButton( + onClick = { + scope.launch { + when (key) { + "ordering" -> SyncedSettings.resetOrdering() + "android" -> SyncedSettings.resetAndroid() + "notifications" -> SyncedSettings.resetNotifications() + } + LoadedSettings.poorlyFormedSettingsKeys -= key + } + } + ) { + Text( + text = stringResource( + R.string.settings_chat_hint_poorly_formed_settings_keys_reset, + when (key) { + "ordering" -> stringResource(R.string.settings_chat_hint_poorly_formed_settings_keys_key_ordering) + "android" -> stringResource(R.string.settings_chat_hint_poorly_formed_settings_keys_key_android) + "notifications" -> stringResource(R.string.settings_chat_hint_poorly_formed_settings_keys_key_notifications) + else -> key + } + ) + ) + } + } + } + } + } + } + ListHeader { Text( text = stringResource(R.string.settings_chat_quick_reply) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8b808e16..17634921 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -660,6 +660,13 @@ Interactive Embeds allow you to interact with some types of links directly inside chat. For example, you can play YouTube videos or preview albums from Apple Music. YouTube Apple Music + Hint + Some of your Revolt settings are corrupted. This may be due to the use of third-party clients. The affected settings are: + Notifications + Server Ordering + Revolt for Android + %1$s (unknown) + Reset %1$s settings Feedback Join the Revolt server to give feedback or suggestions and report bugs.