feat: add new changelogs system

This commit is contained in:
infi 2026-05-15 03:42:40 +02:00
parent c6f90f6bb2
commit 990b495044
16 changed files with 506 additions and 30 deletions

View File

@ -275,6 +275,13 @@ dependencies {
implementation(libs.koin.compose)
implementation(libs.koin.compose.viewmodel.navigation)
implementation(libs.multiplatform.markdown.android)
implementation(libs.multiplatform.markdown.m3)
implementation(libs.multiplatform.markdown.coil3)
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
androidTestImplementation(libs.android.test.core)
androidTestImplementation(libs.android.test.rules)
androidTestImplementation(libs.compose.ui.test.junit4)

View File

@ -1,10 +1,14 @@
package chat.stoat
import android.app.Application
import android.content.Context
import android.os.Build
import android.os.StrictMode
import chat.stoat.di.appModule
import chat.stoat.di.viewModelModule
import coil3.ImageLoader
import coil3.SingletonImageLoader
import coil3.request.crossfade
import com.google.android.material.color.DynamicColors
import logcat.AndroidLogcatLogger
import logcat.LogPriority
@ -12,7 +16,7 @@ import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
import org.koin.core.context.startKoin
class StoatApplication : Application() {
class StoatApplication : Application(), SingletonImageLoader.Factory {
companion object {
lateinit var instance: StoatApplication
}
@ -45,6 +49,12 @@ class StoatApplication : Application() {
}
}
override fun newImageLoader(context: Context): ImageLoader {
return ImageLoader.Builder(context)
.crossfade(true)
.build()
}
init {
instance = this
DynamicColors.applyToActivitiesIfAvailable(this)

View File

@ -94,6 +94,7 @@ import chat.stoat.persistence.KVStorage
import chat.stoat.screens.DefaultDestinationScreen
import chat.stoat.screens.about.AboutScreen
import chat.stoat.screens.about.AttributionScreen
import chat.stoat.screens.changelogs.ReadChangelogScreen
import chat.stoat.screens.chat.ChannelPinsScreen
import chat.stoat.screens.chat.ChatRouterScreen
import chat.stoat.screens.chat.standalone.CatchUpScreen
@ -734,6 +735,8 @@ fun AppEntrypoint(
composable("about/oss") { AttributionScreen(navController) }
composable("labs") { LabsRootScreen(navController) }
composable("changelog/{id}") { ReadChangelogScreen(navController) }
}
}

View File

@ -0,0 +1,62 @@
package chat.stoat.api.routes.microservices.gazette
import chat.stoat.api.HitRateLimitException
import chat.stoat.api.StoatHttp
import chat.stoat.api.StoatJson
import chat.stoat.api.buildUserAgent
import chat.stoat.core.model.data.STOAT_CHANGELOG
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpStatusCode
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class GazetteChangelog(
val id: String,
val title: String,
@SerialName("markdown_content") val markdownContent: String,
@SerialName("ios_version") val iosVersion: String,
@SerialName("android_version") val androidVersion: String,
@SerialName("web_version") val webVersion: String,
@SerialName("published_at") val publishedAt: String,
@SerialName("created_at") val createdAt: String,
@SerialName("updated_at") val updatedAt: String
)
suspend fun getLatestChangelog(): GazetteChangelog {
try {
val response = StoatHttp.get("$STOAT_CHANGELOG/v1/changelogs/latest") {
header("User-Agent", buildUserAgent())
}
if (response.status == HttpStatusCode.OK) {
return StoatJson.decodeFromString(response.bodyAsText())
} else throw Exception("Failed to query changelog: ${response.status.value} ${response.status.description}")
} catch (e: Exception) {
throw Exception("Failed to query changelog: ${e.message}", e).also {
if (e is HitRateLimitException) {
throw e
}
}
}
}
suspend fun getChangelogById(id: String): GazetteChangelog {
try {
val response = StoatHttp.get("$STOAT_CHANGELOG/v1/changelogs/$id") {
header("User-Agent", buildUserAgent())
}
if (response.status == HttpStatusCode.OK) {
return StoatJson.decodeFromString(response.bodyAsText())
} else throw Exception("Failed to query changelog: ${response.status.value} ${response.status.description}")
} catch (e: Exception) {
throw Exception("Failed to query changelog: ${e.message}", e).also {
if (e is HitRateLimitException) {
throw e
}
}
}
}

View File

@ -8,6 +8,7 @@ import chat.stoat.api.routes.sync.setKey
import chat.stoat.core.model.schemas.AndroidSpecificSettings
import chat.stoat.core.model.schemas.NotificationSettings
import chat.stoat.core.model.schemas.OrderingSettings
import chat.stoat.core.model.schemas.ReleaseNotesSettings
import chat.stoat.core.model.schemas._NotificationSettingsToParse
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonPrimitive
@ -33,6 +34,7 @@ object SyncedSettings {
)
)
private val _notifications = mutableStateOf(NotificationSettings())
private val _releaseNotes = mutableStateOf(ReleaseNotesSettings())
val ordering: OrderingSettings
get() = _ordering.value
@ -40,11 +42,13 @@ object SyncedSettings {
get() = _android.value
val notifications: NotificationSettings
get() = _notifications.value
val releaseNotes: ReleaseNotesSettings
get() = _releaseNotes.value
suspend fun fetch(apiToken: String = StoatAPI.sessionToken) {
try {
val settings =
getKeys("ordering", "android", "notifications", token = apiToken)
getKeys("ordering", "android", "notifications", "release-notes", token = apiToken)
settings["ordering"]?.let {
try {
@ -75,6 +79,18 @@ object SyncedSettings {
// Because it is written in typescript and does what it wants
_notifications.value = parseNotificationSettings(it.value)
}
settings["release-notes"]?.let {
try {
_releaseNotes.value = StoatJson.decodeFromString(
ReleaseNotesSettings.serializer(),
it.value
)
} catch (e: Exception) {
LoadedSettings.poorlyFormedSettingsKeys += "release-notes"
e.printStackTrace()
}
}
} catch (e: Exception) {
e.printStackTrace()
}
@ -122,6 +138,11 @@ object SyncedSettings {
setKey("notifications", StoatJson.encodeToString(NotificationSettings.serializer(), value))
}
suspend fun updateReleaseNotes(value: ReleaseNotesSettings) {
_releaseNotes.value = value
setKey("release-notes", StoatJson.encodeToString(ReleaseNotesSettings.serializer(), value))
}
suspend fun resetOrdering() {
val default = OrderingSettings()
_ordering.value = default
@ -147,4 +168,13 @@ object SyncedSettings {
StoatJson.encodeToString(NotificationSettings.serializer(), default)
)
}
suspend fun resetReleaseNotes() {
val default = ReleaseNotesSettings()
_releaseNotes.value = default
setKey(
"release-notes",
StoatJson.encodeToString(ReleaseNotesSettings.serializer(), default)
)
}
}

View File

@ -0,0 +1,63 @@
package chat.stoat.composables.markdown.prose
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.mikepenz.markdown.coil3.Coil3ImageTransformerImpl
import com.mikepenz.markdown.compose.components.markdownComponents
import com.mikepenz.markdown.compose.elements.MarkdownHeader
import com.mikepenz.markdown.compose.elements.MarkdownParagraph
import com.mikepenz.markdown.m3.Markdown
import com.mikepenz.markdown.m3.elements.MarkdownCheckBox
import com.mikepenz.markdown.m3.markdownTypography
import com.mikepenz.markdown.model.rememberMarkdownState
@Composable
fun ProseMarkdown(markdownText: String, modifier: Modifier = Modifier) {
Markdown(
markdownState = rememberMarkdownState(content = markdownText),
imageTransformer = Coil3ImageTransformerImpl,
typography = markdownTypography(
h1 = MaterialTheme.typography.displaySmall.copy(lineHeight = 45.sp),
h2 = MaterialTheme.typography.titleLarge,
h3 = MaterialTheme.typography.titleMedium,
h4 = MaterialTheme.typography.bodyMedium,
h5 = MaterialTheme.typography.bodySmall,
h6 = MaterialTheme.typography.labelSmall,
paragraph = MaterialTheme.typography.bodyLarge.copy(lineHeight = 25.sp),
),
components = markdownComponents(
checkbox = {
MarkdownCheckBox(
it.content,
it.node,
it.typography.text
)
},
paragraph = {
MarkdownParagraph(
it.content,
it.node,
style = it.typography.paragraph,
modifier = Modifier.padding(bottom = 16.dp)
)
},
heading1 = {
Box(
modifier = Modifier.padding(top = 8.dp, bottom = 16.dp)
) {
MarkdownHeader(
it.content,
it.node,
style = it.typography.h1,
)
}
}
),
modifier = modifier
)
}

View File

@ -0,0 +1,193 @@
package chat.stoat.screens.changelogs
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandIn
import androidx.compose.animation.fadeIn
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import chat.stoat.R
import chat.stoat.api.routes.microservices.gazette.GazetteChangelog
import chat.stoat.api.routes.microservices.gazette.getChangelogById
import chat.stoat.composables.markdown.prose.ProseMarkdown
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
sealed interface ReadChangelogScreenUiState {
data object Loading : ReadChangelogScreenUiState
data class Success(val changelog: GazetteChangelog) : ReadChangelogScreenUiState
data class Error(val throwable: Throwable) : ReadChangelogScreenUiState
}
class ReadChangelogScreenViewModel(
handle: SavedStateHandle
) : ViewModel() {
private val id: String = checkNotNull(handle["id"])
private val retry = MutableSharedFlow<Unit>(replay = 1).apply { tryEmit(Unit) }
@OptIn(ExperimentalCoroutinesApi::class)
val state: StateFlow<ReadChangelogScreenUiState> = retry
.flatMapLatest {
flow { emit(ReadChangelogScreenUiState.Success(getChangelogById(id)) as ReadChangelogScreenUiState) }
.catch { emit(ReadChangelogScreenUiState.Error(it)) }
.onStart { emit(ReadChangelogScreenUiState.Loading) }
}
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
ReadChangelogScreenUiState.Loading
)
fun retry() {
retry.tryEmit(Unit)
}
}
@Composable
private fun rememberDateFormatter(): DateTimeFormatter {
val locale = LocalConfiguration.current.locales[0]
return remember(locale) {
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
}
}
@Composable
private fun rememberFormattedDate(isoDateTime: String?): String? {
val formatter = rememberDateFormatter()
val zone = remember { ZoneId.systemDefault() }
return remember(isoDateTime, formatter, zone) {
isoDateTime?.let {
Instant.parse(it).atZone(zone).toLocalDate().format(formatter)
}
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ReadChangelogScreen(
navController: NavController,
viewModel: ReadChangelogScreenViewModel = viewModel()
) {
val state = viewModel.state.collectAsState()
val formattedDate =
rememberFormattedDate((state.value as? ReadChangelogScreenUiState.Success)?.changelog?.publishedAt)
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
stringResource(R.string.changelog_header),
modifier = Modifier.fillMaxWidth()
)
},
subtitle = {
Box(
Modifier.fillMaxWidth(),
) {
AnimatedVisibility(
visible = state.value is ReadChangelogScreenUiState.Success,
enter = fadeIn() + expandIn(expandFrom = Alignment.BottomCenter),
modifier = Modifier.fillMaxWidth()
) {
Text(
formattedDate ?: stringResource(R.string.unknown),
modifier = Modifier.fillMaxWidth()
)
}
}
},
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(
painter = painterResource(R.drawable.ic_arrow_back_24dp),
contentDescription = stringResource(R.string.back)
)
}
}
)
}
) { pv ->
Box(Modifier.padding(pv)) {
AnimatedContent(targetState = state.value) { uiState ->
when (uiState) {
is ReadChangelogScreenUiState.Loading -> Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
LoadingIndicator(
modifier = Modifier.size(72.dp)
)
}
is ReadChangelogScreenUiState.Error -> Column(
Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
stringResource(R.string.changelog_load_error),
modifier = Modifier.padding(16.dp)
)
Button(
onClick = { viewModel.retry() },
modifier = Modifier.padding(top = 8.dp)
) {
Text(stringResource(R.string.changelog_retry))
}
}
is ReadChangelogScreenUiState.Success -> {
ProseMarkdown(
markdownText = uiState.changelog.markdownContent,
modifier = Modifier
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState())
)
}
}
}
}
}
}

View File

@ -1,5 +1,6 @@
package chat.stoat.screens.chat
import android.annotation.SuppressLint
import android.content.Context
import android.os.Build
import android.util.Log
@ -70,11 +71,14 @@ import chat.stoat.api.StoatAPI
import chat.stoat.api.internals.DirectMessages
import chat.stoat.api.realtime.DisconnectionState
import chat.stoat.api.realtime.RealtimeSocket
import chat.stoat.api.routes.microservices.gazette.getLatestChangelog
import chat.stoat.api.routes.push.subscribePush
import chat.stoat.api.settings.SyncedSettings
import chat.stoat.callbacks.Action
import chat.stoat.callbacks.ActionChannel
import chat.stoat.composables.chat.DisconnectedNotice
import chat.stoat.composables.screens.chat.drawer.ChannelSideDrawer
import chat.stoat.core.model.schemas.ReleaseNotesSettings
import chat.stoat.dialogs.NotificationRationaleDialog
import chat.stoat.internals.extensions.zero
import chat.stoat.persistence.KVStorage
@ -100,6 +104,10 @@ import com.google.firebase.messaging.FirebaseMessaging
import io.sentry.Sentry
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import logcat.LogPriority
import logcat.logcat
import org.koin.androidx.compose.koinViewModel
sealed class ChatRouterDestination {
@ -139,17 +147,17 @@ sealed class ChatRouterDestination {
}
}
@SuppressLint("StaticFieldLeak")
class ChatRouterViewModel(
private val kvStorage: KVStorage,
val context: Context
val context: Context,
) : ViewModel() {
var currentDestination by mutableStateOf<ChatRouterDestination>(ChatRouterDestination.default)
var latestChangelogRead by mutableStateOf(true)
var latestChangelog by mutableStateOf("")
var latestChangelogBody by mutableStateOf("")
var showNotificationRationale by mutableStateOf(false)
var showEarlyAccessSpark by mutableStateOf(false)
var showSwipeToReplySpark by mutableStateOf(false)
var showChangelogScreenForId by mutableStateOf<String?>(null)
private var changelogCheckDone = false
init {
viewModelScope.launch {
@ -247,6 +255,40 @@ class ChatRouterViewModel(
}
}
}
fun maybeShowChangelog() {
if (changelogCheckDone) return
changelogCheckDone = true
viewModelScope.launch {
val latestChangelog = runCatching { getLatestChangelog() }
.onFailure {
logcat(LogPriority.ERROR) { "Failed to fetch latest changelog: ${it.message}" }
}
.getOrNull()
if (latestChangelog != null) {
val isInFuture =
runCatching { Instant.parse(latestChangelog.publishedAt) > Clock.System.now() }.getOrNull()
?: false
if (isInFuture) {
logcat(LogPriority.WARN) { "Latest changelog is from the future (${latestChangelog.publishedAt} > ${Clock.System.now()}), not showing it!" }
return@launch
}
val lastSeenChangelog = SyncedSettings.releaseNotes.lastSeenId
if (lastSeenChangelog == null || lastSeenChangelog != latestChangelog.id) {
showChangelogScreenForId = latestChangelog.id
SyncedSettings.updateReleaseNotes(
ReleaseNotesSettings(
lastSeenId = latestChangelog.id,
lastSeenAt = Clock.System.now().toString()
)
)
}
}
}
}
}
val LocalIsConnected = compositionLocalOf(structuralEqualityPolicy()) { false }
@ -360,6 +402,21 @@ fun ChatRouterScreen(
}
}
LaunchedEffect(Unit) {
viewModel.maybeShowChangelog()
}
LaunchedEffect(Unit) {
snapshotFlow { viewModel.showChangelogScreenForId }
.distinctUntilChanged()
.collect { changelogId ->
if (changelogId != null) {
viewModel.showChangelogScreenForId = null
topNav.navigate("changelog/${changelogId}")
}
}
}
LaunchedEffect(DirectMessages.unreadDMs()) {
snapshotFlow { DirectMessages.unreadDMs() }
.distinctUntilChanged()

View File

@ -1,7 +1,7 @@
package chat.stoat.screens.chat.views
import android.content.Intent
import android.util.Log
import android.widget.Toast
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
@ -58,8 +58,10 @@ import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.navigation.NavController
import chat.stoat.R
import chat.stoat.activities.InviteActivity
import chat.stoat.api.StoatAPI
import chat.stoat.api.routes.user.fetchSelf
import chat.stoat.composables.generic.NonIdealState
@ -331,8 +333,7 @@ fun OverviewScreen(
item(key = "changelog") {
OverviewScreenLink(
onClick = {
// navController.navigate("settings/changelogs")
// TODO replace with new changelog
navController.navigate("changelog/latest")
},
backgroundColour = MaterialTheme.colorScheme.errorContainer,
foregroundColour = MaterialTheme.colorScheme.onErrorContainer,
@ -342,7 +343,7 @@ fun OverviewScreen(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
painter = painterResource(R.drawable.ic_wand_shine_24dp),
painter = painterResource(R.drawable.ic_campaign_24dp),
contentDescription = null,
modifier = Modifier.size(22.dp)
)
@ -353,15 +354,16 @@ fun OverviewScreen(
)
}
item(key = "feedback") {
item(key = "join-lounge") {
OverviewScreenLink(
onClick = {
Toast.makeText(
val intent = Intent(
context,
resources.getString(R.string.comingsoon_toast),
Toast.LENGTH_SHORT
).show()
// navController.navigate("feedback")
InviteActivity::class.java
).setAction(Intent.ACTION_VIEW)
intent.data = "https://stt.gg/Testers".toUri()
context.startActivity(intent)
},
backgroundColour = MaterialTheme.colorScheme.primary,
foregroundColour = MaterialTheme.colorScheme.onPrimary,
@ -371,14 +373,14 @@ fun OverviewScreen(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
painter = painterResource(R.drawable.ic_star_shine_24dp),
painter = painterResource(R.drawable.ic_waving_hand_24dp),
contentDescription = null,
modifier = Modifier.size(22.dp)
)
Text(stringResource(R.string.overview_screen_feedback))
Text(stringResource(R.string.overview_screen_join_lounge))
}
},
body = { Text(stringResource(R.string.overview_screen_feedback_description)) }
body = { Text(stringResource(R.string.overview_screen_join_lounge_description)) }
)
}
}

View File

@ -123,6 +123,7 @@ fun ChatSettingsScreen(
"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)
"release-notes" -> stringResource(R.string.settings_chat_hint_poorly_formed_settings_keys_key_release_notes)
else -> stringResource(
R.string.settings_chat_hint_poorly_formed_settings_keys_key_unknown,
key
@ -137,7 +138,7 @@ fun ChatSettingsScreen(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
for (key in LoadedSettings.poorlyFormedSettingsKeys.filter {
it in setOf("ordering", "android", "notifications")
it in setOf("ordering", "android", "notifications", "release-notes")
}) {
TextButton(
onClick = {
@ -146,6 +147,7 @@ fun ChatSettingsScreen(
"ordering" -> SyncedSettings.resetOrdering()
"android" -> SyncedSettings.resetAndroid()
"notifications" -> SyncedSettings.resetNotifications()
"release-notes" -> SyncedSettings.resetReleaseNotes()
}
LoadedSettings.poorlyFormedSettingsKeys -= key
}
@ -158,6 +160,7 @@ fun ChatSettingsScreen(
"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)
"release-notes" -> stringResource(R.string.settings_chat_hint_poorly_formed_settings_keys_key_release_notes)
else -> key
}
)

View File

@ -47,6 +47,7 @@ import androidx.lifecycle.viewModelScope
import androidx.navigation.NavController
import chat.stoat.R
import chat.stoat.api.routes.push.subscribePush
import chat.stoat.api.settings.SyncedSettings
import chat.stoat.dialogs.NotificationRationaleDialog
import chat.stoat.persistence.Database
import chat.stoat.persistence.KVStorage
@ -88,7 +89,7 @@ class DebugSettingsScreenViewModel(
fun forgetLatestChangelog() {
viewModelScope.launch {
kvStorage.remove("latestChangelogRead")
SyncedSettings.resetReleaseNotes()
}
}
@ -276,7 +277,7 @@ fun DebugSettingsScreen(
}
Text(
text = "Changelogs",
text = "Release Notes (Gazette)",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(bottom = 10.dp)
)

View File

@ -315,6 +315,27 @@ fun SettingsScreen(
)
}
ListItem(
headlineContent = {
Text(
text = stringResource(id = R.string.settings_changelog)
)
},
leadingContent = {
SettingsIcon {
Icon(
painter = painterResource(R.drawable.ic_campaign_24dp),
contentDescription = null,
)
}
},
modifier = Modifier
.testTag("settings_view_changelog")
.clickable {
navController.navigate("changelog/latest")
}
)
ListItem(
headlineContent = {
Text(

View File

@ -0,0 +1,9 @@
package chat.stoat.sheets
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@Composable
fun ChangelogSheet(modifier: Modifier = Modifier) {
}

View File

@ -149,10 +149,10 @@
<string name="overview_screen_settings_description">Change your look, manage your account, and more</string>
<string name="overview_screen_share_profile">Share Profile</string>
<string name="overview_screen_share_profile_description">Take a look at your user card and get a special link to share</string>
<string name="overview_screen_changelog">Changelog</string>
<string name="overview_screen_changelog_description">Stoat never sleeps, take a look to see what\'s been in the works</string>
<string name="overview_screen_feedback">Provide Feedback</string>
<string name="overview_screen_feedback_description">Got something to say? We\'re all ears</string>
<string name="overview_screen_changelog">What\'s New?</string>
<string name="overview_screen_changelog_description">See what we\'ve been working on recently and what\'s coming up next</string>
<string name="overview_screen_join_lounge">Join the Stoat server</string>
<string name="overview_screen_join_lounge_description">Hang out with other Stoat users, give feedback, and see what\'s happening with Stoat</string>
<string name="overview_screen_catch_up">Catch Up</string>
<string name="overview_screen_catch_up_description">See what you\'ve missed while you were away</string>
<string name="overview_screen_catch_up_none">All caught up</string>
@ -770,16 +770,14 @@
<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">Stoat for Android</string>
<string name="settings_chat_hint_poorly_formed_settings_keys_key_release_notes">Release Notes</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 Stoat server to give feedback or suggestions and report bugs.</string>
<string name="settings_changelogs">Changelogs</string>
<string name="settings_changelogs_new_header">What\'s been cooking ✨</string>
<string name="settings_changelogs_historical_version_header">Changelog for %1$s</string>
<string name="settings_changelogs_historical_version_header_placeholder">that version</string>
<string name="settings_changelog">What\'s New</string>
<string name="settings_language">Language</string>
<string name="settings_language_auto">System default</string>
@ -833,4 +831,8 @@
<string name="geogate_description">Stoat may block content in certain jurisdictions in response to legislation or legal notices</string>
<string name="geogate_description_variant_osa_uk_25" translatable="false">This channel is not available in your region while we review options on legal compliance.</string>
<string name="geogate_acknowledge">Acknowledge</string>
<string name="changelog_header">What\'s new</string>
<string name="changelog_load_error">There was an error loading the changelog.</string>
<string name="changelog_retry">Retry</string>
</resources>

View File

@ -22,6 +22,12 @@ data class _NotificationSettingsToParse( // quirk
val server: Map<String, JsonElement?> = emptyMap()
)
@Serializable
data class ReleaseNotesSettings(
val lastSeenId: String? = null,
val lastSeenAt: String? = null
)
@Serializable
data class AndroidSpecificSettingsSpecialEmbedSettings(
/**

View File

@ -25,6 +25,8 @@ androidx-test = "1.6.1"
livekit = "2.24.1"
livekit-compose = "2.3.0"
koin = "4.2.1"
multiplatform-markdown = "0.41.0-b01"
coil = "3.4.0"
[libraries]
android-core-ktx = { module = "androidx.core:core-ktx", version = "1.16.0" }
@ -101,6 +103,11 @@ livekit-android-compose = { module = "io.livekit:livekit-android-compose-compone
koin = { module = "io.insert-koin:koin-android", version.ref = "koin" }
koin-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin" }
koin-compose-viewmodel-navigation = { module = "io.insert-koin:koin-compose-viewmodel-navigation", version.ref = "koin" }
multiplatform-markdown-android = { module = "com.mikepenz:multiplatform-markdown-renderer-android", version.ref = "multiplatform-markdown" }
multiplatform-markdown-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "multiplatform-markdown" }
multiplatform-markdown-coil3 = { module = "com.mikepenz:multiplatform-markdown-renderer-coil3", version.ref = "multiplatform-markdown" }
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }