diff --git a/backend/app/services/announcements.py b/backend/app/services/announcements.py index 461256f68..7a9751364 100644 --- a/backend/app/services/announcements.py +++ b/backend/app/services/announcements.py @@ -251,13 +251,21 @@ def parse_entry(raw: object) -> Entry | None: return None link = raw.get("link_url") link = link if isinstance(link, str) and link_allowed(link) else None + published_at = _parse_time(raw.get("published_at")) + expires_at = _parse_time(raw.get("expires_at")) + # History is whatever has expired; the feed's ``archived`` flag says the same + # thing for messages the registrar kept after their expiry. One flagged but + # still in date by this install's clock is history anyway: expire it now. + now = datetime.now(timezone.utc).replace(tzinfo=None) + if raw.get("archived") is True and (expires_at is None or expires_at > now): + expires_at = now return Entry( public_id=public_id, level=level, texts=texts, link_url=link, - published_at=_parse_time(raw.get("published_at")), - expires_at=_parse_time(raw.get("expires_at")), + published_at=published_at, + expires_at=expires_at, ) @@ -344,6 +352,7 @@ async def refresh(db: AsyncSession) -> int | None: """ if not await is_enabled(db): return None + seen_before = int(await _get(db, SERIAL_KEY) or 0) try: content = await _download() payload = verify_feed(content) @@ -360,27 +369,36 @@ async def refresh(db: AsyncSession) -> int | None: await _set(db, LAST_FETCH_KEY, datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")) await db.commit() logger.info("Announcements feed #%s: %d for this install", payload["serial"], count) + if payload["serial"] > seen_before: + await _tell_open_pages() return count +async def _tell_open_pages() -> None: + """Have every open Bambuddy page re-read the list, so a new message's dot and + banner appear without a reload. The event carries nothing: each page asks + GET /announcements, which answers by who is asking.""" + from backend.app.core.websocket import ws_manager + + try: + await ws_manager.broadcast({"type": "announcements_changed"}) + except Exception: # A page that misses it catches up on its own poll. + logger.debug("announcements_changed broadcast failed", exc_info=True) + + # ---- read state ---------------------------------------------------------------------- -def _visible_filter(now: datetime): - return (Announcement.expires_at.is_(None)) | (Announcement.expires_at > now) - - async def list_for(db: AsyncSession, user_id: int | None) -> list[dict]: - """Live announcements, newest first, each with whether this user has read it.""" + """Every stored announcement, newest first, with this user's read state. + + ``archived`` marks history: messages past their expiry, which the panel lists + under "Earlier" and which never count as unread or raise a banner. The feed + decides how much history there is; a message it drops is deleted here. + """ now = datetime.now(timezone.utc).replace(tzinfo=None) rows = ( - ( - await db.execute( - select(Announcement) - .where(_visible_filter(now)) - .order_by(Announcement.published_at.desc(), Announcement.id.desc()) - ) - ) + (await db.execute(select(Announcement).order_by(Announcement.published_at.desc(), Announcement.id.desc()))) .scalars() .all() ) @@ -400,6 +418,7 @@ async def list_for(db: AsyncSession, user_id: int | None) -> list[dict]: "link_url": a.link_url, "published_at": a.published_at.isoformat() + "Z" if a.published_at else None, "expires_at": a.expires_at.isoformat() + "Z" if a.expires_at else None, + "archived": a.expires_at is not None and a.expires_at <= now, "read": a.id in read_ids, } ) diff --git a/backend/tests/unit/test_announcements_service.py b/backend/tests/unit/test_announcements_service.py index b469ec7d9..c3393d975 100644 --- a/backend/tests/unit/test_announcements_service.py +++ b/backend/tests/unit/test_announcements_service.py @@ -244,13 +244,30 @@ class TestApply: class TestReadState: @pytest.mark.asyncio - async def test_expired_ones_are_not_listed(self, db_session): + async def test_expired_ones_are_history(self, db_session): past = (datetime.now(timezone.utc) - timedelta(minutes=1)).strftime("%Y-%m-%dT%H:%M:%SZ") future = (datetime.now(timezone.utc) + timedelta(days=1)).strftime("%Y-%m-%dT%H:%M:%SZ") p = payload(entry("gone", expires_at=past), entry("live", expires_at=future), entry("forever")) await svc.apply_payload(db_session, p, STABLE_DOCKER) await db_session.commit() - assert sorted(i["id"] for i in await svc.list_for(db_session, None)) == ["forever", "live"] + listed = {i["id"]: i["archived"] for i in await svc.list_for(db_session, None)} + assert listed == {"gone": True, "live": False, "forever": False} + + @pytest.mark.asyncio + async def test_archived_in_the_feed_is_history_even_without_an_expiry(self, db_session): + await svc.apply_payload(db_session, payload(entry("old", archived=True)), STABLE_DOCKER) + await db_session.commit() + [item] = await svc.list_for(db_session, None) + assert item["archived"] is True + + @pytest.mark.asyncio + async def test_history_dropped_by_the_feed_is_deleted(self, db_session): + past = (datetime.now(timezone.utc) - timedelta(days=2)).strftime("%Y-%m-%dT%H:%M:%SZ") + await svc.apply_payload(db_session, payload(entry("old", expires_at=past, archived=True)), STABLE_DOCKER) + await db_session.commit() + await svc.apply_payload(db_session, payload(serial=2), STABLE_DOCKER) + await db_session.commit() + assert await svc.list_for(db_session, None) == [] @pytest.mark.asyncio async def test_reading_twice_with_auth_off_records_once(self, db_session): @@ -286,6 +303,21 @@ class TestRefresh: assert await svc.refresh(db_session) == 1 assert await _stored(db_session) == ["a1"] + @pytest.mark.asyncio + async def test_open_pages_are_told_only_when_the_feed_moved_on(self, db_session): + feed = signed(payload(entry("a1"), serial=3)) + with ( + patch.object(svc, "_download", AsyncMock(return_value=feed)), + patch.object(svc, "TRUSTED_KEYS", TRUSTED), + patch.object(svc, "install_facts", AsyncMock(return_value=STABLE_DOCKER)), + patch("backend.app.core.websocket.ws_manager.broadcast", AsyncMock()) as broadcast, + ): + await svc.refresh(db_session) + broadcast.assert_awaited_once_with({"type": "announcements_changed"}) + # The same feed again: nothing new to tell anyone. + await svc.refresh(db_session) + assert broadcast.await_count == 1 + @pytest.mark.asyncio async def test_a_bad_feed_keeps_the_last_good_list(self, db_session): with ( diff --git a/frontend/src/__tests__/components/Announcements.test.tsx b/frontend/src/__tests__/components/Announcements.test.tsx index bad969605..e83b9923c 100644 --- a/frontend/src/__tests__/components/Announcements.test.tsx +++ b/frontend/src/__tests__/components/Announcements.test.tsx @@ -21,27 +21,64 @@ function announcement(overrides: Partial = {}): Announcement { link_url: null, published_at: '2026-10-01T12:00:00Z', expires_at: null, + archived: false, read: false, ...overrides, }; } describe('AnnouncementsPanel', () => { - it('marks what was unread as read on opening, and keeps a New chip on it', () => { + it('lists titles collapsed, marks unread ones New, and marks read only on opening one', () => { const markRead = vi.fn(); render( {}} markRead={markRead} - announcements={[announcement(), announcement({ id: 'a2', read: true, texts: { en: { title: 'Old news', body: 'x' } } })]} + announcements={[ + announcement({ texts: { en: { title: 'Fresh', body: 'Fresh body' } } }), + announcement({ id: 'a2', read: true, texts: { en: { title: 'Old news', body: 'Old body' } } }), + ]} /> ); - expect(markRead).toHaveBeenCalledTimes(1); - expect(markRead).toHaveBeenCalledWith('a1'); const items = screen.getAllByRole('listitem'); expect(within(items[0]).getByText('New')).toBeInTheDocument(); expect(within(items[1]).queryByText('New')).not.toBeInTheDocument(); + // Collapsed: titles only, nothing read yet. + expect(screen.queryByText('Fresh body')).not.toBeInTheDocument(); + expect(markRead).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: /Fresh/ })); + expect(screen.getByText('Fresh body')).toBeInTheDocument(); + expect(markRead).toHaveBeenCalledWith('a1'); + + // Opening one already read doesn't mark it again. + fireEvent.click(screen.getByRole('button', { name: /Old news/ })); + expect(screen.getByText('Old body')).toBeInTheDocument(); + expect(markRead).toHaveBeenCalledTimes(1); + + // And it closes again. + fireEvent.click(screen.getByRole('button', { name: /Fresh/ })); + expect(screen.queryByText('Fresh body')).not.toBeInTheDocument(); + }); + + it('opens with the message the banner pointed at expanded', () => { + const markRead = vi.fn(); + render( + {}} + markRead={markRead} + announcements={[ + announcement({ id: 'a', texts: { en: { title: 'A', body: 'A body' } } }), + announcement({ id: 'b', texts: { en: { title: 'B', body: 'B body' } } }), + ]} + /> + ); + expect(screen.getByText('B body')).toBeInTheDocument(); + expect(screen.queryByText('A body')).not.toBeInTheDocument(); + expect(markRead).toHaveBeenCalledWith('b'); }); it('renders the body as plain text, never as HTML', () => { @@ -53,6 +90,7 @@ describe('AnnouncementsPanel', () => { announcements={[announcement({ texts: { en: { title: 'T', body: '' } } })]} /> ); + fireEvent.click(screen.getByText('T')); expect(screen.getByText('')).toBeInTheDocument(); expect(document.querySelector('img[src="x"]')).toBeNull(); }); @@ -69,6 +107,8 @@ describe('AnnouncementsPanel', () => { ]} /> ); + fireEvent.click(screen.getByText('A')); + fireEvent.click(screen.getByText('B')); const link = screen.getByRole('link', { name: /Details/ }); expect(link).toHaveAttribute('href', 'https://wiki.bambuddy.cool/x/'); expect(link).toHaveAttribute('rel', 'noopener noreferrer'); @@ -83,6 +123,29 @@ describe('AnnouncementsPanel', () => { }); }); +describe('AnnouncementsPanel history', () => { + it('lists expired messages under a collapsed Earlier section and never marks them', () => { + const markRead = vi.fn(); + render( + {}} + markRead={markRead} + announcements={[ + announcement({ id: 'old', archived: true, texts: { en: { title: 'Old news', body: 'x' } } }), + ]} + /> + ); + expect(screen.getByText('No announcements right now.')).toBeInTheDocument(); + expect(screen.queryByText('Old news')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Earlier (1)' })); + expect(screen.getByText('Old news')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /Old news/ })); + expect(screen.queryByText('New')).not.toBeInTheDocument(); + expect(markRead).not.toHaveBeenCalled(); + }); +}); + describe('AnnouncementBanner', () => { it('shows the first item, offers the rest, and Got it marks it read', () => { const markRead = vi.fn(); @@ -99,7 +162,7 @@ describe('AnnouncementBanner', () => { ); expect(screen.getByRole('alert')).toHaveTextContent('Update now'); fireEvent.click(screen.getByRole('button', { name: 'Read more (+1)' })); - expect(onOpen).toHaveBeenCalled(); + expect(onOpen).toHaveBeenCalledWith('crit'); fireEvent.click(screen.getByRole('button', { name: 'Got it' })); expect(markRead).toHaveBeenCalledWith('crit'); }); @@ -127,6 +190,18 @@ describe('Layout with announcements', () => { expect(screen.queryByText('Announcements')).not.toBeInTheDocument(); }); + it('keeps the entry for history alone, with no count and no banner', async () => { + server.use( + http.get('/api/v1/announcements', () => + HttpResponse.json([announcement({ id: 'old', level: 'critical', archived: true })]) + ) + ); + render(); + const entry = await screen.findByRole('button', { name: /announcements/i }); + expect(within(entry).queryByText('1')).not.toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + it('shows the entry with an unread count, the banner for important, and opens the list', async () => { let reads: string[] = []; server.use( @@ -143,7 +218,7 @@ describe('Layout with announcements', () => { ); render(); - const entry = await screen.findByRole('button', { name: /Announcements/ }); + const entry = await screen.findByRole('button', { name: /announcements/i }); expect(within(entry).getByText('2')).toBeInTheDocument(); // Only the important one earns a banner. expect(screen.getByRole('status')).toHaveTextContent('Breaking change in 2.0'); @@ -152,8 +227,12 @@ describe('Layout with announcements', () => { fireEvent.click(entry); const dialog = await screen.findByRole('dialog'); expect(within(dialog).getByText('Testers wanted')).toBeInTheDocument(); - await waitFor(() => expect(reads.sort()).toEqual(['imp', 'inf'])); - // Read now: the banner is gone. + // Opening the panel reads nothing; opening a message reads that one. + expect(reads).toEqual([]); + fireEvent.click(within(dialog).getByRole('button', { name: /Breaking change in 2.0/ })); + await waitFor(() => expect(reads).toEqual(['imp'])); + // Read now: its banner is gone, the info one is still unread. await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument()); + expect(within(dialog).getAllByText('New')).toHaveLength(1); }); }); diff --git a/frontend/src/__tests__/hooks/useAnnouncements.test.ts b/frontend/src/__tests__/hooks/useAnnouncements.test.ts index 99ed77c06..6dd8c95c3 100644 --- a/frontend/src/__tests__/hooks/useAnnouncements.test.ts +++ b/frontend/src/__tests__/hooks/useAnnouncements.test.ts @@ -3,7 +3,7 @@ import { announcementText, isAllowedAnnouncementLink } from '../../hooks/useAnno import type { Announcement } from '../../api/client'; const a = (texts: Announcement['texts']): Announcement => ({ - id: 'x', level: 'info', texts, link_url: null, published_at: null, expires_at: null, read: false, + id: 'x', level: 'info', texts, link_url: null, published_at: null, expires_at: null, archived: false, read: false, }); describe('announcementText', () => { diff --git a/frontend/src/__tests__/hooks/useWebSocket.test.ts b/frontend/src/__tests__/hooks/useWebSocket.test.ts index dbce7ecd0..e9658ea66 100644 --- a/frontend/src/__tests__/hooks/useWebSocket.test.ts +++ b/frontend/src/__tests__/hooks/useWebSocket.test.ts @@ -438,6 +438,36 @@ describe('useWebSocket hook', () => { vi.unstubAllGlobals(); }); + it('re-reads announcements on announcements_changed, so the dot appears without a reload', async () => { + vi.useFakeTimers(); + const { useWebSocket } = await import('../../hooks/useWebSocket'); + + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + renderHook(() => useWebSocket(), { + wrapper: createWrapper(queryClient), + }); + + const ws = await waitForWs(); + + act(() => { + ws.open(); + }); + + act(() => { + ws.simulateMessage({ type: 'announcements_changed' }); + }); + + await act(async () => { + vi.advanceTimersByTime(5000); + }); + + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['announcements'] }); + + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + it('invalidates inventory queries on inventory_changed message', async () => { vi.useFakeTimers(); const { useWebSocket } = await import('../../hooks/useWebSocket'); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 1494222c7..c6cb230b4 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -9216,6 +9216,8 @@ export interface Announcement { link_url: string | null; published_at: string | null; expires_at: string | null; + // Past its expiry: kept as history, listed under "Earlier", never unread. + archived: boolean; read: boolean; } diff --git a/frontend/src/components/AnnouncementBanner.tsx b/frontend/src/components/AnnouncementBanner.tsx index cb11ed82c..29573771b 100644 --- a/frontend/src/components/AnnouncementBanner.tsx +++ b/frontend/src/components/AnnouncementBanner.tsx @@ -6,7 +6,8 @@ import { announcementText } from '../hooks/useAnnouncements'; interface AnnouncementBannerProps { // Unread important/critical announcements, most severe first. items: Announcement[]; - onOpen: () => void; + // Opens the panel with this message expanded. + onOpen: (id: string) => void; markRead: (id: string) => void; } @@ -40,7 +41,7 @@ export function AnnouncementBanner({ items, onOpen, markRead }: AnnouncementBann {text.title} + {showEarlier && ( +
    + {earlier.map((a) => ( + + ))} +
+ )} + + )}