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 <infi@infi.sh>
This commit is contained in:
Infi 2025-02-02 03:34:35 +01:00
parent 38b171bd97
commit 0660771dd3
9 changed files with 265 additions and 23 deletions

View File

@ -14,21 +14,27 @@ object DirectMessages {
) && it.active == true && it.lastMessageID != null ) && it.active == true && it.lastMessageID != null
} }
.filter { .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 { fun hasPlatformModerationDM(): Boolean {
return unreadDMs().any { return unreadDMs().any {
it.channelType == ChannelType.DirectMessage && it.channelType == ChannelType.DirectMessage &&
it.recipients?.contains(PLATFORM_MODERATION_USER) ?: false it.recipients?.contains(PLATFORM_MODERATION_USER) ?: false
} }
} }
fun getPlatformModerationDM(): Channel? { fun getPlatformModerationDM(): Channel? {
return unreadDMs().firstOrNull { return unreadDMs().firstOrNull {
it.channelType == ChannelType.DirectMessage && it.channelType == ChannelType.DirectMessage &&
it.recipients?.contains(PLATFORM_MODERATION_USER) ?: false it.recipients?.contains(PLATFORM_MODERATION_USER) ?: false
} }
} }
} }

View File

@ -2,12 +2,26 @@ package chat.revolt.api.schemas
import chat.revolt.ui.theme.OverridableColourScheme import chat.revolt.ui.theme.OverridableColourScheme
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
@Serializable @Serializable
data class OrderingSettings( data class OrderingSettings(
val servers: List<String> = emptyList() val servers: List<String> = emptyList()
) )
@Serializable
data class NotificationSettings(
val channel: Map<String, String> = emptyMap(),
val server: Map<String, String> = emptyMap()
)
@Serializable
data class _NotificationSettingsToParse( // quirk
val channel: Map<String, JsonElement?> = emptyMap(),
val server: Map<String, JsonElement?> = emptyMap()
)
@Serializable @Serializable
data class AndroidSpecificSettingsSpecialEmbedSettings( data class AndroidSpecificSettingsSpecialEmbedSettings(
/** /**

View File

@ -22,6 +22,7 @@ object LoadedSettings {
var avatarRadius by mutableIntStateOf(50) var avatarRadius by mutableIntStateOf(50)
var experimentsEnabled by mutableStateOf(false) var experimentsEnabled by mutableStateOf(false)
var specialEmbedSettings by mutableStateOf(SpecialEmbedSettings()) var specialEmbedSettings by mutableStateOf(SpecialEmbedSettings())
var poorlyFormedSettingsKeys by mutableStateOf(emptySet<String>())
fun hydrateWithSettings(settings: SyncedSettings) { fun hydrateWithSettings(settings: SyncedSettings) {
this.theme = settings.android.theme?.let { Theme.valueOf(it) } ?: getDefaultTheme() this.theme = settings.android.theme?.let { Theme.valueOf(it) } ?: getDefaultTheme()
@ -37,5 +38,6 @@ object LoadedSettings {
messageReplyStyle = MessageReplyStyle.SwipeFromEnd messageReplyStyle = MessageReplyStyle.SwipeFromEnd
avatarRadius = 50 avatarRadius = 50
specialEmbedSettings = SpecialEmbedSettings() specialEmbedSettings = SpecialEmbedSettings()
poorlyFormedSettingsKeys = emptySet()
} }
} }

View File

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

View File

@ -6,7 +6,21 @@ import chat.revolt.api.RevoltJson
import chat.revolt.api.routes.sync.getKeys import chat.revolt.api.routes.sync.getKeys
import chat.revolt.api.routes.sync.setKey import chat.revolt.api.routes.sync.setKey
import chat.revolt.api.schemas.AndroidSpecificSettings import chat.revolt.api.schemas.AndroidSpecificSettings
import chat.revolt.api.schemas.NotificationSettings
import chat.revolt.api.schemas.OrderingSettings 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 { object SyncedSettings {
private val _ordering = mutableStateOf(OrderingSettings()) private val _ordering = mutableStateOf(OrderingSettings())
@ -17,34 +31,81 @@ object SyncedSettings {
messageReplyStyle = "None" messageReplyStyle = "None"
) )
) )
private val _notifications = mutableStateOf(NotificationSettings())
val ordering: OrderingSettings val ordering: OrderingSettings
get() = _ordering.value get() = _ordering.value
val android: AndroidSpecificSettings val android: AndroidSpecificSettings
get() = _android.value get() = _android.value
val notifications: NotificationSettings
get() = _notifications.value
suspend fun fetch(revoltToken: String = RevoltAPI.sessionToken) { suspend fun fetch(revoltToken: String = RevoltAPI.sessionToken) {
try { try {
val settings = getKeys("ordering", "android", revoltToken = revoltToken) val settings =
getKeys("ordering", "android", "notifications", revoltToken = revoltToken)
settings["ordering"]?.let { settings["ordering"]?.let {
_ordering.value = RevoltJson.decodeFromString( try {
OrderingSettings.serializer(), _ordering.value = RevoltJson.decodeFromString(
it.value OrderingSettings.serializer(),
) it.value
)
} catch (e: Exception) {
LoadedSettings.poorlyFormedSettingsKeys += "ordering"
e.printStackTrace()
}
} }
settings["android"]?.let { settings["android"]?.let {
_android.value = RevoltJson.decodeFromString( try {
AndroidSpecificSettings.serializer(), _android.value = RevoltJson.decodeFromString(
it.value 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) { } catch (e: Exception) {
e.printStackTrace() 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) { suspend fun updateOrdering(value: OrderingSettings) {
_ordering.value = value _ordering.value = value
setKey("ordering", RevoltJson.encodeToString(OrderingSettings.serializer(), value)) setKey("ordering", RevoltJson.encodeToString(OrderingSettings.serializer(), value))
@ -54,4 +115,34 @@ object SyncedSettings {
_android.value = value _android.value = value
setKey("android", RevoltJson.encodeToString(AndroidSpecificSettings.serializer(), 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)
)
}
} }

View File

@ -10,6 +10,7 @@ import chat.revolt.api.routes.server.ackServer
import chat.revolt.api.routes.sync.syncUnreads import chat.revolt.api.routes.sync.syncUnreads
import chat.revolt.api.schemas.ChannelType import chat.revolt.api.schemas.ChannelType
import chat.revolt.api.schemas.ChannelUnread import chat.revolt.api.schemas.ChannelUnread
import chat.revolt.api.settings.NotificationSettingsProvider
class Unreads { class Unreads {
private val hasLoaded = mutableStateOf(false) private val hasLoaded = mutableStateOf(false)
@ -34,13 +35,15 @@ class Unreads {
hasLoaded.value = true hasLoaded.value = true
} }
fun getForChannel(channelId: String): ChannelUnread? { fun getForChannel(channelId: String, serverId: String?): ChannelUnread? {
if (!hasLoaded.value) return null if (!hasLoaded.value) return null
if (NotificationSettingsProvider.isChannelMuted(channelId, serverId)) return null
return channels[channelId] 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 (!hasLoaded.value) return false
if (NotificationSettingsProvider.isChannelMuted(channelId, serverId)) return false
return (channels[channelId]?.last_id?.compareTo(lastMessageId) ?: 0) < 0 return (channels[channelId]?.last_id?.compareTo(lastMessageId) ?: 0) < 0
} }
@ -48,11 +51,15 @@ class Unreads {
if (!hasLoaded.value) return false if (!hasLoaded.value) return false
return RevoltAPI.serverCache[serverId]?.channels?.any { return RevoltAPI.serverCache[serverId]?.channels?.any {
val channel = RevoltAPI.channelCache[it] ?: return@any false val channel = RevoltAPI.channelCache[it] ?: return@any false // Channel not found
if (channel.channelType == ChannelType.VoiceChannel) return@any false // TODO remove this when text in voice channels is implemented if (channel.channelType == ChannelType.VoiceChannel) return@any false // Channel is voice
hasUnread(it, channel.lastMessageID ?: "") if (NotificationSettingsProvider.isChannelMuted(
} it,
?: false 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) { suspend fun markAsRead(channelId: String, messageId: String, sync: Boolean = true) {

View File

@ -87,6 +87,7 @@ import chat.revolt.api.schemas.ChannelType
import chat.revolt.api.schemas.ServerFlags import chat.revolt.api.schemas.ServerFlags
import chat.revolt.api.schemas.User import chat.revolt.api.schemas.User
import chat.revolt.api.schemas.has import chat.revolt.api.schemas.has
import chat.revolt.api.settings.NotificationSettingsProvider
import chat.revolt.api.settings.SyncedSettings import chat.revolt.api.settings.SyncedSettings
import chat.revolt.components.generic.GroupIcon import chat.revolt.components.generic.GroupIcon
import chat.revolt.components.generic.IconPlaceholder import chat.revolt.components.generic.IconPlaceholder
@ -552,7 +553,8 @@ fun ChannelSideDrawer(
onDestinationChanged, onDestinationChanged,
drawerState, drawerState,
channelListState, channelListState,
onOpenChannelContextSheet = { channelContextSheetTarget = it } onOpenChannelContextSheet = { channelContextSheetTarget = it },
serverId = currentServer
) )
} }
} }
@ -687,9 +689,12 @@ fun ColumnScope.DirectMessagesChannelListRenderer(
}, },
hasUnread = channel.lastMessageID?.let { lastMessageID -> hasUnread = channel.lastMessageID?.let { lastMessageID ->
RevoltAPI.unreads.hasUnread( RevoltAPI.unreads.hasUnread(
channel.id!!, lastMessageID channel.id!!,
lastMessageID,
serverId = null
) )
} ?: false, } ?: false,
isMuted = NotificationSettingsProvider.isChannelMuted(channel.id!!, null),
onDestinationChanged = { dest -> onDestinationChanged = { dest ->
onDestinationChanged(dest) onDestinationChanged(dest)
scope.launch { scope.launch {
@ -719,6 +724,7 @@ fun ColumnScope.ServerChannelListRenderer(
drawerState: DrawerState?, drawerState: DrawerState?,
channelListState: LazyListState, channelListState: LazyListState,
onOpenChannelContextSheet: (String) -> Unit, onOpenChannelContextSheet: (String) -> Unit,
serverId: String
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -772,9 +778,15 @@ fun ColumnScope.ServerChannelListRenderer(
}, },
hasUnread = channelOrCat.channel.lastMessageID?.let { lastMessageID -> hasUnread = channelOrCat.channel.lastMessageID?.let { lastMessageID ->
RevoltAPI.unreads.hasUnread( RevoltAPI.unreads.hasUnread(
channelOrCat.channel.id!!, lastMessageID channelOrCat.channel.id!!,
lastMessageID,
serverId
) )
} ?: false, } ?: false,
isMuted = NotificationSettingsProvider.isChannelMuted(
channelOrCat.channel.id!!,
serverId
),
onOpenChannelContextSheet = onOpenChannelContextSheet onOpenChannelContextSheet = onOpenChannelContextSheet
) )
} }
@ -812,6 +824,7 @@ fun ChannelItem(
channel.channelType ?: ChannelType.TextChannel channel.channelType ?: ChannelType.TextChannel
), ),
hasUnread: Boolean = false, hasUnread: Boolean = false,
isMuted: Boolean = false,
appendServerName: Boolean = false, appendServerName: Boolean = false,
onDestinationChanged: (ChatRouterDestination) -> Unit, onDestinationChanged: (ChatRouterDestination) -> Unit,
onOpenChannelContextSheet: (String) -> Unit onOpenChannelContextSheet: (String) -> Unit
@ -854,6 +867,13 @@ fun ChannelItem(
Modifier Modifier
} }
) )
.then(
if (isMuted) {
Modifier.alpha(0.5f)
} else {
Modifier
}
)
.padding(16.dp) .padding(16.dp)
.fillMaxWidth()) { .fillMaxWidth()) {
when (iconType) { when (iconType) {
@ -910,6 +930,7 @@ fun DMOrGroupItem(
partner: User?, partner: User?,
isCurrent: Boolean, isCurrent: Boolean,
hasUnread: Boolean, hasUnread: Boolean,
isMuted: Boolean = false,
onDestinationChanged: (ChatRouterDestination) -> Unit, onDestinationChanged: (ChatRouterDestination) -> Unit,
onOpenChannelContextSheet: (String) -> Unit onOpenChannelContextSheet: (String) -> Unit
) { ) {
@ -941,6 +962,13 @@ fun DMOrGroupItem(
) )
.padding(vertical = 16.dp) .padding(vertical = 16.dp)
.fillMaxWidth() .fillMaxWidth()
.then(
if (isMuted) {
Modifier.alpha(0.5f)
} else {
Modifier
}
)
) { ) {
Box( Box(
Modifier Modifier

View File

@ -1,7 +1,10 @@
package chat.revolt.screens.settings package chat.revolt.screens.settings
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column 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.fillMaxSize
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -10,16 +13,20 @@ import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -54,13 +61,14 @@ class ChatSettingsScreenViewModel : ViewModel() {
} }
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
fun ChatSettingsScreen( fun ChatSettingsScreen(
navController: NavController, navController: NavController,
viewModel: ChatSettingsScreenViewModel = viewModel() viewModel: ChatSettingsScreenViewModel = viewModel()
) { ) {
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
val scope = rememberCoroutineScope()
Scaffold( Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
@ -95,6 +103,73 @@ fun ChatSettingsScreen(
.fillMaxSize() .fillMaxSize()
.verticalScroll(scrollState) .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 { ListHeader {
Text( Text(
text = stringResource(R.string.settings_chat_quick_reply) text = stringResource(R.string.settings_chat_quick_reply)

View File

@ -660,6 +660,13 @@
<string name="settings_chat_interactive_embeds_description">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.</string> <string name="settings_chat_interactive_embeds_description">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.</string>
<string name="settings_chat_interactive_embeds_youtube">YouTube</string> <string name="settings_chat_interactive_embeds_youtube">YouTube</string>
<string name="settings_chat_interactive_embeds_apple_music">Apple Music</string> <string name="settings_chat_interactive_embeds_apple_music">Apple Music</string>
<string name="settings_chat_hint_poorly_formed_settings_keys">Hint</string>
<string name="settings_chat_hint_poorly_formed_settings_keys_description">Some of your Revolt settings are corrupted. This may be due to the use of third-party clients. The affected settings are:</string>
<string name="settings_chat_hint_poorly_formed_settings_keys_key_notifications">Notifications</string>
<string name="settings_chat_hint_poorly_formed_settings_keys_key_ordering">Server Ordering</string>
<string name="settings_chat_hint_poorly_formed_settings_keys_key_android">Revolt for Android</string>
<string name="settings_chat_hint_poorly_formed_settings_keys_key_unknown">%1$s (unknown)</string>
<string name="settings_chat_hint_poorly_formed_settings_keys_reset">Reset %1$s settings</string>
<string name="settings_feedback">Feedback</string> <string name="settings_feedback">Feedback</string>
<string name="settings_feedback_description">Join the Revolt server to give feedback or suggestions and report bugs.</string> <string name="settings_feedback_description">Join the Revolt server to give feedback or suggestions and report bugs.</string>