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)
}
}
+13 -2
View File
@@ -44,8 +44,19 @@ export async function markNotificationRead(id: string): Promise<void> {
await api.post(`/api/me/notifications/${encodeURIComponent(id)}/read`, {});
}
export async function markAllNotificationsRead(): Promise<void> {
await api.post('/api/me/notifications/read-all', {});
/** upTo limits it to notices created at or before then, so one the inbox never showed stays unread. */
export async function markAllNotificationsRead(upTo?: string): Promise<void> {
await api.post('/api/me/notifications/read-all', upTo ? { up_to: upTo } : {});
}
/**
* The read-all cutoff for what the inbox shows: the newest notice, plus a
* millisecond. The server keeps microseconds and Date only milliseconds, so
* without the rounding up the newest notice would stay unread.
*/
export function readAllCutoff(items: NotificationItem[]): string | undefined {
const newest = Math.max(...items.map((i) => Date.parse(i.created_at)).filter((t) => !Number.isNaN(t)));
return Number.isFinite(newest) ? new Date(newest + 1).toISOString() : undefined;
}
export function createNotificationsQuery() {
+39 -26
View File
@@ -1,4 +1,6 @@
<script lang="ts">
import { untrack } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
import { goto } from '$app/navigation';
import { useQueryClient } from '@tanstack/svelte-query';
import { Bell } from 'lucide-svelte';
@@ -8,13 +10,14 @@
createUnreadCountQuery,
markAllNotificationsRead,
markNotificationRead,
readAllCutoff,
type NotificationItem
} from '#lib/api/notifications.js';
import { relativeTime } from '#lib/utils/relativeTime.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
// The notifications inbox (M489): a bell in the header with an unread badge,
// opening a panel of what happened while you were away.
// The notifications inbox: a bell in the header with an unread badge,
// opening a panel of what happened while you were away. Opening the panel
// is reading it: what it shows is marked read at once.
const client = useQueryClient();
const unreadStore = createUnreadCountQuery();
@@ -41,6 +44,34 @@
client.invalidateQueries({ queryKey: ['notifications'] });
}
// Notices that were unread when the panel showed them. They keep looking
// new until it closes, so marking them read doesn't hide what is new.
const fresh = new SvelteSet<string>();
$effect(() => {
if (!open) return;
const unseen = items.filter((i) => !i.read_at && !fresh.has(i.id));
if (unseen.length === 0) return;
untrack(() => markSeen(unseen));
});
$effect(() => {
if (!open) untrack(() => fresh.clear());
});
async function markSeen(unseen: NotificationItem[]) {
for (const i of unseen) fresh.add(i.id);
try {
await markAllNotificationsRead(readAllCutoff(unseen));
} catch {
// The badge stays until the next open marks them, which is the honest state.
return;
}
refresh();
}
const looksNew = (item: NotificationItem) => !item.read_at || fresh.has(item.id);
async function openItem(item: NotificationItem) {
open = false;
if (!item.read_at) {
@@ -54,15 +85,6 @@
}
if (item.link) await goto(item.link);
}
async function markAll() {
try {
await markAllNotificationsRead();
refresh();
} catch {
pushToast("Couldn't mark them read. Try again in a moment.", 'error');
}
}
</script>
<svelte:window onclick={handleWindowClick} onkeydown={(e) => e.key === 'Escape' && (open = false)} />
@@ -96,17 +118,8 @@
role="dialog"
aria-label="Notifications"
>
<div class="flex items-center justify-between border-b border-border px-3 py-2">
<div class="border-b border-border px-3 py-2">
<span class="text-sm font-medium text-text-primary">Notifications</span>
{#if unread > 0}
<button
type="button"
class="rounded px-2 py-1 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary focus-visible:ring-2 focus-visible:ring-accent"
onclick={markAll}
>
Mark all read
</button>
{/if}
</div>
{#if items.length === 0}
@@ -118,20 +131,20 @@
<button
type="button"
data-testid="notification-item"
data-unread={item.read_at ? undefined : 'true'}
data-unread={looksNew(item) ? 'true' : undefined}
class="flex w-full gap-2 px-3 py-2 text-left hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent"
onclick={() => openItem(item)}
>
<span
class="mt-1.5 h-2 w-2 shrink-0 rounded-full {item.read_at ? '' : 'bg-text-primary'}"
class="mt-1.5 h-2 w-2 shrink-0 rounded-full {looksNew(item) ? 'bg-text-primary' : ''}"
aria-hidden="true"
></span>
<span class="min-w-0 flex-1">
<span
class="block text-sm {item.read_at ? 'text-text-secondary' : 'font-medium text-text-primary'}"
class="block text-sm {looksNew(item) ? 'font-medium text-text-primary' : 'text-text-secondary'}"
>
{item.title}
{#if !item.read_at}<span class="sr-only"> (unread)</span>{/if}
{#if looksNew(item)}<span class="sr-only"> (new)</span>{/if}
</span>
{#if item.body}
<span class="block text-sm text-text-secondary">{item.body}</span>
@@ -1,5 +1,7 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import { tick } from 'svelte';
import { writable } from 'svelte/store';
import { mockQuery } from '../../test-utils/query';
const invalidateMock = vi.fn();
@@ -12,6 +14,7 @@ vi.mock('#lib/api/notifications.js', async (orig) => {
const actual = (await orig()) as Record<string, unknown>;
return {
badgeLabel: actual.badgeLabel,
readAllCutoff: actual.readAllCutoff,
createUnreadCountQuery: vi.fn(),
createNotificationsQuery: vi.fn(),
markNotificationRead: vi.fn().mockResolvedValue(undefined),
@@ -19,8 +22,6 @@ vi.mock('#lib/api/notifications.js', async (orig) => {
};
});
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
import NotificationBell from './NotificationBell.svelte';
import { goto } from '$app/navigation';
import {
@@ -29,6 +30,7 @@ import {
createUnreadCountQuery,
markAllNotificationsRead,
markNotificationRead,
readAllCutoff,
type NotificationItem
} from '#lib/api/notifications.js';
@@ -137,21 +139,74 @@ describe('NotificationBell', () => {
expect(markNotificationRead).not.toHaveBeenCalled();
});
test('mark all read clears everything and refreshes', async () => {
setInbox([item(), item({ id: 'n2' })]);
test('opening the panel marks what it shows read, up to the newest notice', async () => {
setInbox([
item({ id: 'new', created_at: '2026-10-23T12:00:00.123456Z' }),
item({ id: 'older', created_at: '2026-10-23T11:00:00Z' })
]);
render(NotificationBell);
await openPanel();
await fireEvent.click(screen.getByRole('button', { name: 'Mark all read' }));
expect(markAllNotificationsRead).not.toHaveBeenCalled();
await waitFor(() => expect(markAllNotificationsRead).toHaveBeenCalled());
await openPanel();
await waitFor(() =>
expect(markAllNotificationsRead).toHaveBeenCalledWith('2026-10-23T12:00:00.124Z')
);
expect(markAllNotificationsRead).toHaveBeenCalledTimes(1);
expect(invalidateMock).toHaveBeenCalledWith({ queryKey: ['notifications'] });
});
test('an empty inbox says so and offers no mark all', async () => {
test('a notice read on opening still looks new until the panel closes', async () => {
const page = (items: NotificationItem[]) => ({
data: { items, unread_count: 0 },
isPending: false,
isError: false,
refetch: () => {}
});
const list = writable(page([item({ id: 'n1' })]));
(createUnreadCountQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: 1 }));
(createNotificationsQuery as ReturnType<typeof vi.fn>).mockReturnValue(list);
render(NotificationBell);
await openPanel();
await waitFor(() => expect(markAllNotificationsRead).toHaveBeenCalled());
// The refetch after marking brings the row back read.
list.set(page([item({ id: 'n1', read_at: new Date().toISOString() })]));
await tick();
expect(screen.getByTestId('notification-item')).toHaveAttribute('data-unread', 'true');
await openPanel(); // closes
await openPanel(); // opens again
expect(screen.getByTestId('notification-item')).not.toHaveAttribute('data-unread');
});
test('opening an inbox with nothing unread marks nothing', async () => {
setInbox([item({ read_at: new Date().toISOString() })]);
render(NotificationBell);
await openPanel();
expect(markAllNotificationsRead).not.toHaveBeenCalled();
});
test('an empty inbox says so', async () => {
setInbox([]);
render(NotificationBell);
await openPanel();
expect(screen.getByText('Nothing waiting for you.')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Mark all read' })).toBeNull();
expect(markAllNotificationsRead).not.toHaveBeenCalled();
});
});
describe('readAllCutoff', () => {
test('is a millisecond past the newest notice, so its microseconds are covered', () => {
expect(
readAllCutoff([
item({ created_at: '2026-10-23T11:00:00Z' }),
item({ created_at: '2026-10-23T12:00:00.123456Z' })
])
).toBe('2026-10-23T12:00:00.124Z');
});
test('is undefined with nothing to mark', () => {
expect(readAllCutoff([])).toBeUndefined();
});
});
+1
View File
@@ -33,6 +33,7 @@ vi.mock('#lib/api/notifications.js', async () => {
createNotificationsQuery: () => mockQuery({ data: { items: [], unread_count: 0 } }),
markNotificationRead: vi.fn(),
markAllNotificationsRead: vi.fn(),
readAllCutoff: () => undefined,
badgeLabel: () => ''
};
});