feat: notification settings screen

This commit is contained in:
infi 2026-05-24 21:48:38 +02:00
parent 2b84cbb342
commit 51c6dc7fc0
10 changed files with 314 additions and 12 deletions

View File

@ -75,7 +75,6 @@ import chat.stoat.R
import chat.stoat.StoatApplication import chat.stoat.StoatApplication
import chat.stoat.api.HitRateLimitException import chat.stoat.api.HitRateLimitException
import chat.stoat.api.StoatAPI import chat.stoat.api.StoatAPI
import chat.stoat.c2dm.NotificationDeepLink
import chat.stoat.api.StoatHttp import chat.stoat.api.StoatHttp
import chat.stoat.api.api import chat.stoat.api.api
import chat.stoat.api.routes.microservices.geo.queryGeo import chat.stoat.api.routes.microservices.geo.queryGeo
@ -85,6 +84,7 @@ import chat.stoat.api.settings.Experiments
import chat.stoat.api.settings.GeoStateProvider import chat.stoat.api.settings.GeoStateProvider
import chat.stoat.api.settings.LoadedSettings import chat.stoat.api.settings.LoadedSettings
import chat.stoat.api.settings.SyncedSettings import chat.stoat.api.settings.SyncedSettings
import chat.stoat.c2dm.NotificationDeepLink
import chat.stoat.composables.generic.HealthAlert import chat.stoat.composables.generic.HealthAlert
import chat.stoat.composables.voice.VoicePermissionSwitch import chat.stoat.composables.voice.VoicePermissionSwitch
import chat.stoat.composables.voice.VoiceSheet import chat.stoat.composables.voice.VoiceSheet
@ -117,6 +117,7 @@ import chat.stoat.screens.settings.ChatSettingsScreen
import chat.stoat.screens.settings.DebugSettingsScreen import chat.stoat.screens.settings.DebugSettingsScreen
import chat.stoat.screens.settings.ExperimentsSettingsScreen import chat.stoat.screens.settings.ExperimentsSettingsScreen
import chat.stoat.screens.settings.LanguagePickerSettingsScreen import chat.stoat.screens.settings.LanguagePickerSettingsScreen
import chat.stoat.screens.settings.NotificationsSettingsScreen
import chat.stoat.screens.settings.ProfileSettingsScreen import chat.stoat.screens.settings.ProfileSettingsScreen
import chat.stoat.screens.settings.SessionSettingsScreen import chat.stoat.screens.settings.SessionSettingsScreen
import chat.stoat.screens.settings.SettingsScreen import chat.stoat.screens.settings.SettingsScreen
@ -712,6 +713,7 @@ fun AppEntrypoint(
composable("settings/sessions") { SessionSettingsScreen(navController) } composable("settings/sessions") { SessionSettingsScreen(navController) }
composable("settings/appearance") { AppearanceSettingsScreen(navController) } composable("settings/appearance") { AppearanceSettingsScreen(navController) }
composable("settings/chat") { ChatSettingsScreen(navController) } composable("settings/chat") { ChatSettingsScreen(navController) }
composable("settings/notifications") { NotificationsSettingsScreen(navController) }
composable("settings/debug") { DebugSettingsScreen(navController) } composable("settings/debug") { DebugSettingsScreen(navController) }
composable("settings/experiments") { ExperimentsSettingsScreen(navController) } composable("settings/experiments") { ExperimentsSettingsScreen(navController) }
composable("settings/language") { LanguagePickerSettingsScreen(navController) } composable("settings/language") { LanguagePickerSettingsScreen(navController) }
@ -738,7 +740,7 @@ fun AppEntrypoint(
composable("about/oss") { AttributionScreen(navController) } composable("about/oss") { AttributionScreen(navController) }
composable("labs") { LabsRootScreen(navController) } composable("labs") { LabsRootScreen(navController) }
composable("changelog/{id}") { ReadChangelogScreen(navController) } composable("changelog/{id}") { ReadChangelogScreen(navController) }
} }
} }

View File

@ -23,4 +23,8 @@ suspend fun subscribePush(
setBody(data) setBody(data)
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
} }
}
suspend fun unsubscribePush() {
StoatHttp.post("/push/unsubscribe".api())
} }

View File

@ -84,19 +84,31 @@ class HandlerService : FirebaseMessagingService() {
val messageTimestamp = ULID.asTimestamp(messageId) val messageTimestamp = ULID.asTimestamp(messageId)
val db = Database(SqlStorage.driver) val db = Database(SqlStorage.driver)
val channelName = db.channelQueries.findById(channelId).executeAsOneOrNull()?.let {
when (it.channelType) { fun serverPrefix(serverId: String?): String? {
"DirectMessage" -> authorName if (serverId == null) return null
"TextChannel" -> "#${it.name}" return db.serverQueries.findById(serverId).executeAsOneOrNull()?.name
else -> it.name ?: authorName }
fun formatChannelName(type: String, name: String?, serverId: String?): String {
val base = when (type) {
"DirectMessage" -> return authorName
"TextChannel" -> "#${name}"
else -> name ?: return authorName
} }
val prefix = serverPrefix(serverId) ?: return base
return "$prefix · $base"
}
val channelName = db.channelQueries.findById(channelId).executeAsOneOrNull()?.let {
formatChannelName(it.channelType, it.name, it.server)
} ?: runBlocking { } ?: runBlocking {
runCatching { fetchSingleChannel(channelId) }.getOrNull()?.let { runCatching { fetchSingleChannel(channelId) }.getOrNull()?.let {
when (it.channelType) { formatChannelName(
ChannelType.DirectMessage -> authorName it.channelType?.value ?: "",
ChannelType.TextChannel -> "#${it.name}" it.name,
else -> it.name ?: authorName it.server
} )
} ?: authorName } ?: authorName
} }

View File

@ -0,0 +1,56 @@
package chat.stoat.composables.generic
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemColors
import androidx.compose.material3.ListItemDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
@Composable
fun CenteredListItem(
headlineContent: @Composable () -> Unit,
modifier: Modifier = Modifier,
overlineContent: @Composable (() -> Unit)? = null,
supportingContent: @Composable (() -> Unit)? = null,
leadingContent: @Composable (() -> Unit)? = null,
trailingContent: @Composable (() -> Unit)? = null,
colors: ListItemColors = ListItemDefaults.colors(),
tonalElevation: Dp = ListItemDefaults.Elevation,
shadowElevation: Dp = ListItemDefaults.Elevation
) {
ListItem(
modifier = modifier.height(IntrinsicSize.Min),
headlineContent = headlineContent,
overlineContent = overlineContent,
supportingContent = supportingContent,
colors = colors,
tonalElevation = tonalElevation,
shadowElevation = shadowElevation,
leadingContent = leadingContent?.let { content ->
{
Box(
modifier = Modifier.fillMaxHeight(),
contentAlignment = Alignment.Center
) {
content()
}
}
},
trailingContent = trailingContent?.let { content ->
{
Box(
modifier = Modifier.fillMaxHeight(),
contentAlignment = Alignment.Center
) {
content()
}
}
}
)
}

View File

@ -8,6 +8,7 @@ import chat.stoat.screens.login.LoginViewModel
import chat.stoat.screens.login.MfaScreenViewModel import chat.stoat.screens.login.MfaScreenViewModel
import chat.stoat.screens.settings.AppearanceSettingsScreenViewModel import chat.stoat.screens.settings.AppearanceSettingsScreenViewModel
import chat.stoat.screens.settings.DebugSettingsScreenViewModel import chat.stoat.screens.settings.DebugSettingsScreenViewModel
import chat.stoat.screens.settings.NotificationsSettingsScreenViewModel
import chat.stoat.screens.settings.ProfileSettingsScreenViewModel import chat.stoat.screens.settings.ProfileSettingsScreenViewModel
import chat.stoat.screens.settings.SettingsScreenViewModel import chat.stoat.screens.settings.SettingsScreenViewModel
import chat.stoat.screens.settings.channel.ChannelSettingsOverviewViewModel import chat.stoat.screens.settings.channel.ChannelSettingsOverviewViewModel
@ -26,6 +27,7 @@ val viewModelModule = module {
viewModel { MfaScreenViewModel(get()) } viewModel { MfaScreenViewModel(get()) }
viewModel { SettingsScreenViewModel(get()) } viewModel { SettingsScreenViewModel(get()) }
viewModel { DebugSettingsScreenViewModel(get()) } viewModel { DebugSettingsScreenViewModel(get()) }
viewModel { NotificationsSettingsScreenViewModel(get(), androidContext()) }
viewModel { LoginViewModel(get()) } viewModel { LoginViewModel(get()) }
viewModel { ProfileSettingsScreenViewModel(androidApplication()) } viewModel { ProfileSettingsScreenViewModel(androidApplication()) }
viewModel { AppearanceSettingsScreenViewModel(androidApplication()) } viewModel { AppearanceSettingsScreenViewModel(androidApplication()) }

View File

@ -0,0 +1,185 @@
package chat.stoat.screens.settings
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.os.Build
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavController
import chat.stoat.R
import chat.stoat.api.routes.push.subscribePush
import chat.stoat.api.routes.push.unsubscribePush
import chat.stoat.composables.generic.CenteredListItem
import chat.stoat.dialogs.NotificationRationaleDialog
import chat.stoat.persistence.KVStorage
import chat.stoat.settings.dsl.SettingsPage
import com.google.android.gms.tasks.OnCompleteListener
import com.google.firebase.messaging.FirebaseMessaging
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
@SuppressLint("StaticFieldLeak")
class NotificationsSettingsScreenViewModel(
private val kvStorage: KVStorage,
private val context: Context
) : ViewModel() {
var showRationale by mutableStateOf(false)
var isPushEnabled by mutableStateOf(false)
private set
var isUpdating by mutableStateOf(false)
private set
init {
viewModelScope.launch {
isPushEnabled = checkPushEnabled()
}
}
private suspend fun checkPushEnabled(): Boolean {
val hasPermission = NotificationManagerCompat.from(context).areNotificationsEnabled()
val hasToken = kvStorage.get("fcmToken") != null
return hasPermission && hasToken
}
fun onEnableRequested() {
showRationale = true
}
fun subscribeIfNeeded() {
if (isUpdating) return
isUpdating = true
FirebaseMessaging.getInstance().token.addOnCompleteListener(
OnCompleteListener { task ->
if (!task.isSuccessful) {
isUpdating = false
return@OnCompleteListener
}
val newToken = task.result
viewModelScope.launch {
try {
val existingToken = kvStorage.get("fcmToken")
if (existingToken != newToken) {
subscribePush(auth = newToken)
kvStorage.set("fcmToken", newToken)
}
kvStorage.remove("pushNotificationsRejected")
isPushEnabled = checkPushEnabled()
} catch (e: Exception) {
// subscribe failed, leave state unchanged
} finally {
isUpdating = false
}
}
}
)
}
fun disablePush() {
if (isUpdating) return
isUpdating = true
viewModelScope.launch {
try {
val token = kvStorage.get("fcmToken")
if (token != null) {
runCatching { unsubscribePush() }
kvStorage.remove("fcmToken")
}
kvStorage.set("pushNotificationsRejected", true)
isPushEnabled = false
} finally {
isUpdating = false
}
}
}
}
@Composable
fun NotificationsSettingsScreen(
navController: NavController,
viewModel: NotificationsSettingsScreenViewModel = koinViewModel()
) {
val context = LocalContext.current
val askNotificationsPermission = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) viewModel.subscribeIfNeeded()
}
if (viewModel.showRationale) {
NotificationRationaleDialog(
onSelected = { accepted ->
if (accepted) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
askNotificationsPermission.launch(android.Manifest.permission.POST_NOTIFICATIONS)
} else {
viewModel.subscribeIfNeeded()
}
}
},
onDismiss = { viewModel.showRationale = false }
)
}
SettingsPage(
navController = navController,
title = { Text(stringResource(R.string.settings_notifications)) }
) {
CenteredListItem(
headlineContent = { Text(stringResource(R.string.settings_notifications_push)) },
supportingContent = { Text(stringResource(R.string.settings_notifications_push_description)) },
trailingContent = {
Switch(
checked = viewModel.isPushEnabled,
onCheckedChange = null,
enabled = !viewModel.isUpdating
)
},
modifier = Modifier
.semantics { role = Role.Switch }
.clickable(enabled = !viewModel.isUpdating) {
if (viewModel.isPushEnabled) viewModel.disablePush()
else viewModel.onEnableRequested()
}
)
CenteredListItem(
headlineContent = { Text(stringResource(R.string.settings_notifications_system)) },
supportingContent = { Text(stringResource(R.string.settings_notifications_system_description)) },
trailingContent = {
Icon(
painter = painterResource(R.drawable.ic_arrow_forward_24dp),
contentDescription = null,
modifier = Modifier.padding(horizontal = 16.dp)
)
},
modifier = Modifier.clickable {
val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
}
context.startActivity(intent)
}
)
}
}

View File

@ -211,6 +211,26 @@ fun SettingsScreen(
navController.navigate("settings/chat") navController.navigate("settings/chat")
} }
) )
ListItem(
headlineContent = {
Text(
text = stringResource(id = R.string.settings_notifications)
)
},
leadingContent = {
SettingsIcon {
Icon(
painter = painterResource(R.drawable.ic_notifications_24dp),
contentDescription = null,
)
}
},
modifier = Modifier
.testTag("settings_view_notifications")
.clickable {
navController.navigate("settings/notifications")
}
)
ListHeader { ListHeader {
Text(stringResource(R.string.settings_category_miscellaneous)) Text(stringResource(R.string.settings_category_miscellaneous))

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M160,760L160,680L240,680L240,400Q240,317 290,252.5Q340,188 420,168L420,140Q420,115 437.5,97.5Q455,80 480,80Q505,80 522.5,97.5Q540,115 540,140L540,168Q620,188 670,252.5Q720,317 720,400L720,680L800,680L800,760L160,760ZM480,460L480,460L480,460L480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460ZM480,880Q447,880 423.5,856.5Q400,833 400,800L560,800Q560,833 536.5,856.5Q513,880 480,880ZM320,680L640,680L640,400Q640,334 593,287Q546,240 480,240Q414,240 367,287Q320,334 320,400L320,680Z"/>
</vector>

View File

@ -775,6 +775,12 @@
<string name="settings_chat_hint_poorly_formed_settings_keys_key_unknown">%1$s (unknown)</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_chat_hint_poorly_formed_settings_keys_reset">Reset %1$s settings</string>
<string name="settings_notifications">Notifications</string>
<string name="settings_notifications_push">Push Notifications</string>
<string name="settings_notifications_push_description">Receive notifications for new messages, mentions, and other updates, even when you\'re not actively using the app.</string>
<string name="settings_notifications_system">System Settings</string>
<string name="settings_notifications_system_description">Manage notification permissions and preferences from your device\'s system settings.</string>
<string name="settings_feedback">Feedback</string> <string name="settings_feedback">Feedback</string>
<string name="settings_feedback_description">Join the Stoat server to give feedback or suggestions and report bugs.</string> <string name="settings_feedback_description">Join the Stoat server to give feedback or suggestions and report bugs.</string>

View File

@ -31,4 +31,9 @@ FROM Server;
delete: delete:
DELETE DELETE
FROM Server FROM Server
WHERE id = ?;
findById:
SELECT *
FROM Server
WHERE id = ?; WHERE id = ?;