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:
parent
38b171bd97
commit
0660771dd3
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue