feat: add new changelogs system
This commit is contained in:
parent
c6f90f6bb2
commit
990b495044
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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())
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
package chat.stoat.sheets
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
@Composable
|
||||
fun ChangelogSheet(modifier: Modifier = Modifier) {
|
||||
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
Loading…
Reference in New Issue