feat: opening the notifications inbox marks what it shows as read (#6275)
release / govulncheck (push) Successful in 22s
release / web (push) Successful in 1m23s
release / go (push) Successful in 1m39s
release / android (push) Failing after 4m25s
release / integration (push) Successful in 5m8s
release / Build signed APK (releases and dev) (push) Successful in 5m33s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped

The web panel and the Android Notifications screen now mark every unread
notice they show as read the moment they appear, so the badge clears on a
look instead of a dismissal. Rows that were unread keep looking new until
the inbox closes. The read goes through read-all with a cutoff of the newest
notice shown plus 1 ms, so one that arrived unseen stays unread. 'Mark all
read' is gone; opening the inbox does its job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 09:41:37 -04:00
co-authored by Claude Opus 5.5
parent 4756e957ce
commit f381622d20
7 changed files with 231 additions and 51 deletions
@@ -16,8 +16,9 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -45,8 +46,9 @@ import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
/**
* The notifications inbox (M489): one row per notice, its title on one line
* and how long ago. Tapping a row marks it read and opens what it is about.
* The notifications inbox: one row per notice, its title on one line and how
* long ago. Showing the screen marks what it shows read; those rows still look
* new until the screen goes. Tapping a row opens what it is about.
*/
@Composable
fun NotificationsScreen(
@@ -54,7 +56,9 @@ fun NotificationsScreen(
viewModel: NotificationsViewModel = hiltViewModel(),
) {
val items by viewModel.items.collectAsStateWithLifecycle()
val anyUnread = items?.any { it.readAt == null } == true
val fresh by viewModel.fresh.collectAsStateWithLifecycle()
LaunchedEffect(items) { items?.let(viewModel::seen) }
DisposableEffect(Unit) { onDispose(viewModel::left) }
Scaffold(
contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(),
@@ -64,11 +68,6 @@ fun NotificationsScreen(
navController = navController,
currentRouteName = Notifications::class.qualifiedName,
onBack = { navController.popBackStack() },
actions = {
if (anyUnread) {
TextButton(onClick = viewModel::markAllRead) { Text("Mark all read") }
}
},
)
},
) { inner ->
@@ -88,6 +87,7 @@ fun NotificationsScreen(
items(list, key = { it.id }) { item ->
NotificationRow(
item = item,
unread = item.readAt == null || item.id in fresh,
onClick = {
viewModel.open(item)
routeForLink(item.link)?.let { navController.navigate(it) }
@@ -102,15 +102,14 @@ fun NotificationsScreen(
}
@Composable
private fun NotificationRow(item: CachedNotificationEntity, onClick: () -> Unit) {
val unread = item.readAt == null
private fun NotificationRow(item: CachedNotificationEntity, unread: Boolean, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 14.dp)
.semantics(mergeDescendants = true) {
if (unread) contentDescription = "Unread: ${item.title}"
if (unread) contentDescription = "New: ${item.title}"
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
@@ -8,16 +8,23 @@ import com.fabledsword.minstrel.connectivity.recoveries
import com.fabledsword.minstrel.notifications.data.NotificationsRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
/** `items` is null until the cache has been read once, so the screen can tell "loading" from "empty". */
/**
* `items` is null until the cache has been read once, so the screen can tell "loading" from "empty".
* Showing the inbox is reading it: what the screen shows is marked read at once, and stays in
* [fresh], so it still looks new while the screen is up.
*/
@HiltViewModel
class NotificationsViewModel @Inject constructor(
private val repository: NotificationsRepository,
@@ -27,6 +34,11 @@ class NotificationsViewModel @Inject constructor(
.map<List<CachedNotificationEntity>, List<CachedNotificationEntity>?> { it }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), null)
private val freshIds = MutableStateFlow<Set<String>>(emptySet())
/** Notices that were unread when the screen showed them. */
val fresh: StateFlow<Set<String>> = freshIds.asStateFlow()
init {
refresh()
// Back online: the cached page may be stale (#1245's recovery idiom).
@@ -43,7 +55,16 @@ class NotificationsViewModel @Inject constructor(
viewModelScope.launch { repository.markRead(item.id) }
}
fun markAllRead() {
/** The screen is showing [shown]: any unread among them are now read. */
fun seen(shown: List<CachedNotificationEntity>) {
val unread = shown.filter { it.readAt == null && it.id !in freshIds.value }
if (unread.isEmpty()) return
freshIds.update { it + unread.map(CachedNotificationEntity::id) }
viewModelScope.launch { repository.markAllRead() }
}
/** The screen has gone: next time, only what arrived since looks new. */
fun left() {
freshIds.value = emptySet()
}
}
@@ -0,0 +1,80 @@
package com.fabledsword.minstrel.notifications.ui
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationEntity
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.notifications.data.NotificationsRepository
import com.fabledsword.minstrel.testutil.MainDispatcherExtension
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.datetime.Instant
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import kotlin.test.assertEquals
/** Showing the inbox reads it: unread notices are marked read once, and keep looking new until the screen goes. */
@ExtendWith(MainDispatcherExtension::class)
class NotificationsViewModelTest {
private val repository: NotificationsRepository = mockk(relaxed = true) {
every { notifications } returns emptyFlow()
}
private val network: NetworkStatusController = mockk {
every { state } returns MutableStateFlow(ServerHealth.Healthy)
}
private val viewModel = NotificationsViewModel(repository, network)
private fun row(id: String, read: Boolean) = CachedNotificationEntity(
id = id,
kind = "request_completed",
title = "t",
body = "b",
link = "/requests",
createdAt = Instant.parse("2026-10-23T12:00:00Z"),
readAt = if (read) Instant.parse("2026-10-23T12:05:00Z") else null,
)
@Test
fun `showing unread notices marks them read and keeps them looking new`() {
viewModel.seen(listOf(row("a", read = false), row("b", read = true)))
coVerify(exactly = 1) { repository.markAllRead() }
assertEquals(setOf("a"), viewModel.fresh.value)
}
@Test
fun `the same notices shown again are not marked twice`() {
viewModel.seen(listOf(row("a", read = false)))
// The local mark lands and the cache re-emits; a re-emit before it lands repeats the row unread.
viewModel.seen(listOf(row("a", read = false)))
viewModel.seen(listOf(row("a", read = true)))
coVerify(exactly = 1) { repository.markAllRead() }
}
@Test
fun `a notice arriving while the screen is up is marked too`() {
viewModel.seen(listOf(row("a", read = false)))
viewModel.seen(listOf(row("new", read = false), row("a", read = true)))
coVerify(exactly = 2) { repository.markAllRead() }
assertEquals(setOf("a", "new"), viewModel.fresh.value)
}
@Test
fun `nothing unread marks nothing`() {
viewModel.seen(listOf(row("a", read = true)))
coVerify(exactly = 0) { repository.markAllRead() }
}
@Test
fun `leaving the screen stops them looking new`() {
viewModel.seen(listOf(row("a", read = false)))
viewModel.left()
assertEquals(emptySet(), viewModel.fresh.value)
}
}