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
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:
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: () => ''
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user