From 3fae2b402b47fd7b877f0147500c088598c37898 Mon Sep 17 00:00:00 2001 From: maziggy Date: Thu, 1 Oct 2026 11:37:06 +0200 Subject: [PATCH] Announcements: history, collapsed list, instant refresh, footer icon - Expired messages stay readable under a collapsed "Earlier" section, as long as the feed keeps them (12 months, at most 50). They never count as unread or raise a banner; withdrawn ones are gone everywhere. - Each message is one row (level, date, title) that opens in place. Unread ones carry a dot and a New chip, and opening one is what marks it read; the banner's Read more opens the panel on its message. - A fetch that brings a newer feed broadcasts an empty announcements_changed event, so open pages show the new dot and banner without a reload. - The sidebar entry is a megaphone icon with an unread badge, in the footer row left of System. Footer icons are 32px with no gap so seven fit an expanded sidebar; with authentication on, logout used to wrap onto a line of its own. --- CHANGELOG.md | 2 +- backend/app/services/announcements.py | 47 ++- .../tests/unit/test_announcements_service.py | 36 ++- .../components/Announcements.test.tsx | 95 +++++- .../__tests__/hooks/useAnnouncements.test.ts | 2 +- .../src/__tests__/hooks/useWebSocket.test.ts | 30 ++ frontend/src/api/client.ts | 2 + .../src/components/AnnouncementBanner.tsx | 5 +- .../src/components/AnnouncementsPanel.tsx | 177 ++++++++---- frontend/src/components/Layout.tsx | 54 ++-- frontend/src/hooks/useAnnouncements.ts | 3 +- frontend/src/hooks/useWebSocket.ts | 6 + frontend/src/i18n/locales/de.ts | 1 + frontend/src/i18n/locales/en.ts | 1 + frontend/src/i18n/locales/es.ts | 1 + frontend/src/i18n/locales/fr.ts | 1 + frontend/src/i18n/locales/it.ts | 1 + frontend/src/i18n/locales/ja.ts | 1 + frontend/src/i18n/locales/ko.ts | 1 + frontend/src/i18n/locales/nl.ts | 1 + frontend/src/i18n/locales/pt-BR.ts | 1 + frontend/src/i18n/locales/ru.ts | 1 + frontend/src/i18n/locales/sv.ts | 1 + frontend/src/i18n/locales/tr.ts | 1 + frontend/src/i18n/locales/uk.ts | 1 + frontend/src/i18n/locales/zh-CN.ts | 1 + frontend/src/i18n/locales/zh-TW.ts | 1 + ...FVKMB.js => ImagePreviewModal-gsnEQ0Oa.js} | 2 +- ...mQWC7Vk.js => PdfPreviewModal-VeiGrj3M.js} | 4 +- ...js => SpreadsheetPreviewModal-Ws1DaBxm.js} | 2 +- static/assets/index-CvbMEZlt.css | 2 - .../{index-CceJ9Gq0.js => index-XSkcXiLG.js} | 272 +++++++++--------- static/assets/index-b50e2nk4.css | 2 + .../{pdf-BDJGJY3X.js => pdf-vgFNYDS8.js} | 2 +- static/index.html | 4 +- 35 files changed, 517 insertions(+), 247 deletions(-) rename static/assets/{ImagePreviewModal-BdqFVKMB.js => ImagePreviewModal-gsnEQ0Oa.js} (98%) rename static/assets/{PdfPreviewModal-BmQWC7Vk.js => PdfPreviewModal-VeiGrj3M.js} (96%) rename static/assets/{SpreadsheetPreviewModal-oT1WTbs3.js => SpreadsheetPreviewModal-Ws1DaBxm.js} (97%) delete mode 100644 static/assets/index-CvbMEZlt.css rename static/assets/{index-CceJ9Gq0.js => index-XSkcXiLG.js} (66%) create mode 100644 static/assets/index-b50e2nk4.css rename static/assets/{pdf-BDJGJY3X.js => pdf-vgFNYDS8.js} (99%) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce27a73f9..59d551dff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to Bambuddy will be documented in this file. ## [1.2.6b1] - Unreleased ### Added -- **Announcements from the Bambuddy maintainers, inside Bambuddy** — Security fixes, breaking changes, new releases and calls for testers now reach the people running Bambuddy where they already look, instead of only on GitHub and Discord. While there is one, an **Announcements** entry sits at the bottom of the sidebar above the System icon, with a count of unread messages; it opens a list, and **important** and **critical** messages also show a banner until dismissed. Read state is kept per user on the server. Bambuddy fetches one file, `feed.json` from the public `maziggy/bambuddy-notifications` repo on GitHub, at startup and every 6 hours: no Bambuddy server is contacted and nothing about the install is sent, and whether a message applies (version range, beta channel, install type) is decided locally. The file is signed with Ed25519 against a key built into Bambuddy, so a copy of the repo or anyone in between can't make it show a message, and an older file is refused so a withdrawn message can't be brought back. Messages are plain text, and links go only to github.com and bambuddy.cool. Shown to administrators; **Settings → General → Updates** can show them to every user, or turn them off entirely, in which case nothing is fetched. +- **Announcements from the Bambuddy maintainers, inside Bambuddy** — Security fixes, breaking changes, new releases and calls for testers now reach the people running Bambuddy where they already look, instead of only on GitHub and Discord. While there is one, an **Announcements** entry sits at the bottom of the sidebar above the System icon, with a count of unread messages; it opens a list, and **important** and **critical** messages also show a banner until dismissed. Messages past their expiry stay readable under **Earlier** for a year (up to 50); withdrawn ones disappear everywhere. Read state is kept per user on the server. Bambuddy fetches one file, `feed.json` from the public `maziggy/bambuddy-notifications` repo on GitHub, at startup and every 6 hours: no Bambuddy server is contacted and nothing about the install is sent, and whether a message applies (version range, beta channel, install type) is decided locally. The file is signed with Ed25519 against a key built into Bambuddy, so a copy of the repo or anyone in between can't make it show a message, and an older file is refused so a withdrawn message can't be brought back. Messages are plain text, and links go only to github.com and bambuddy.cool. Shown to administrators; **Settings → General → Updates** can show them to every user, or turn them off entirely, in which case nothing is fetched. - **Camera snapshots reach more notifications and more providers, with an Attach Photo switch per provider (#3089, requested and contributed by @bbbenji in #3199)** — Plate Not Empty now carries a photo of the plate, taken before the chamber light is switched back off, and AI Failure Detection carries the frame the model flagged. Home Assistant (with a notify service set), Bark and Slack-format webhooks get photos too: they fetch the image themselves, so Bambuddy saves it under a random name and sends a link that opens only that one photo and stops working after 3 days. This needs **External URL** set in **Settings → Network**. Each provider has an **Attach Photo** toggle, on by default so nothing changes for existing setups, and **Test** sends a sample image when it is on. The print emails sent to the user who queued a job can show the finish photo inline when the template contains `{finish_photo_url}`. The template editor shows which events can carry a photo, and lists the variables for AI Failure Detection, Plate Not Empty, Plate Clear Required and First Layer Complete. The chamber light switched on for the plate check is now always switched back off, even when the check fails. - **Combine several STLs, or several copies of one, onto one plate (#2999, requested by @Markus98, contributed by @adman234 in #3162)** — The slicer sidecar slices one model at a time, so putting separate STLs on one plate needed a desktop slicer to build the 3MF first. Select one or more STLs in the File Manager and click **Combine to 3MF**, set how many copies of each you want, and Bambuddy saves a new 3MF with every object on one plate, with a preview image. Tick **Open the slicer when done** to go straight to the Slice dialog with auto-arrange already on. The source STLs are left untouched. A plate holds at most 100 objects, and the selected STLs can be at most 300 MB and 5 million triangles in total; each STL is stored once however many copies you place. - **The API-key printer status carries layers, the job id, HMS faults and the serial (#2919, requested by @simplytoast1)** — `GET /api/v1/webhook/printer/{id}/status`, the compact status route polled by add-ons such as notify-bambuddy for iOS Live Activities, returned only state, progress and time remaining. It now also returns `layer_num` and `total_layers`, the printer's `subtask_id` for the running job (a new value means a new print, even between two polls; `null` when Bambu gives the job no id), the live `hms_errors` in the same shape as the printer status route (so a filament runout reads differently from a manual pause), and the printer's `serial_number`. `remaining_time` stays in minutes for existing clients; the new `remaining_seconds` gives the same estimate in seconds, the unit notifications use. Nothing existing changed, and the route still needs **Read Status** and honours a key's printer limits. 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) => ( + + ))} +
+ )} + + )}