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

View File

@ -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<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
data class AndroidSpecificSettingsSpecialEmbedSettings(
/**

View File

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

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

View File

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

View File

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

View File

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

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_youtube">YouTube</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_description">Join the Revolt server to give feedback or suggestions and report bugs.</string>