Browse Source

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.
maziggy 1 day ago
parent
commit
3fae2b402b
35 changed files with 375 additions and 106 deletions
  1. 1 1
      CHANGELOG.md
  2. 32 13
      backend/app/services/announcements.py
  3. 34 2
      backend/tests/unit/test_announcements_service.py
  4. 87 8
      frontend/src/__tests__/components/Announcements.test.tsx
  5. 1 1
      frontend/src/__tests__/hooks/useAnnouncements.test.ts
  6. 30 0
      frontend/src/__tests__/hooks/useWebSocket.test.ts
  7. 2 0
      frontend/src/api/client.ts
  8. 3 2
      frontend/src/components/AnnouncementBanner.tsx
  9. 129 48
      frontend/src/components/AnnouncementsPanel.tsx
  10. 29 25
      frontend/src/components/Layout.tsx
  11. 2 1
      frontend/src/hooks/useAnnouncements.ts
  12. 6 0
      frontend/src/hooks/useWebSocket.ts
  13. 1 0
      frontend/src/i18n/locales/de.ts
  14. 1 0
      frontend/src/i18n/locales/en.ts
  15. 1 0
      frontend/src/i18n/locales/es.ts
  16. 1 0
      frontend/src/i18n/locales/fr.ts
  17. 1 0
      frontend/src/i18n/locales/it.ts
  18. 1 0
      frontend/src/i18n/locales/ja.ts
  19. 1 0
      frontend/src/i18n/locales/ko.ts
  20. 1 0
      frontend/src/i18n/locales/nl.ts
  21. 1 0
      frontend/src/i18n/locales/pt-BR.ts
  22. 1 0
      frontend/src/i18n/locales/ru.ts
  23. 1 0
      frontend/src/i18n/locales/sv.ts
  24. 1 0
      frontend/src/i18n/locales/tr.ts
  25. 1 0
      frontend/src/i18n/locales/uk.ts
  26. 1 0
      frontend/src/i18n/locales/zh-CN.ts
  27. 1 0
      frontend/src/i18n/locales/zh-TW.ts
  28. 0 0
      static/assets/ImagePreviewModal-gsnEQ0Oa.js
  29. 0 1
      static/assets/PdfPreviewModal-VeiGrj3M.js
  30. 0 0
      static/assets/SpreadsheetPreviewModal-Ws1DaBxm.js
  31. 0 1
      static/assets/index-CvbMEZlt.css
  32. 1 1
      static/assets/index-XSkcXiLG.js
  33. 1 0
      static/assets/index-b50e2nk4.css
  34. 0 0
      static/assets/pdf-vgFNYDS8.js
  35. 2 2
      static/index.html

+ 1 - 1
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.

+ 32 - 13
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
 
 
-# ---- read state ----------------------------------------------------------------------
+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)
 
 
-def _visible_filter(now: datetime):
-    return (Announcement.expires_at.is_(None)) | (Announcement.expires_at > now)
+# ---- read state ----------------------------------------------------------------------
 
 
 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,
             }
         )

+ 34 - 2
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 (

+ 87 - 8
frontend/src/__tests__/components/Announcements.test.tsx

@@ -21,27 +21,64 @@ function announcement(overrides: Partial<Announcement> = {}): 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(
       <AnnouncementsPanel
         open
         onClose={() => {}}
         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(
+      <AnnouncementsPanel
+        open
+        focusId="b"
+        onClose={() => {}}
+        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: '<img src=x onerror=alert(1)>' } } })]}
       />
     );
+    fireEvent.click(screen.getByText('T'));
     expect(screen.getByText('<img src=x onerror=alert(1)>')).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(
+      <AnnouncementsPanel
+        open
+        onClose={() => {}}
+        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(<Layout />);
+    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(<Layout />);
 
-    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);
   });
 });

+ 1 - 1
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', () => {

+ 30 - 0
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');

+ 2 - 0
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;
 }
 

+ 3 - 2
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}
         </span>
         <button
-          onClick={onOpen}
+          onClick={() => onOpen(first.id)}
           className={`font-medium underline ${
             critical
               ? 'text-red-700 dark:text-red-400 hover:text-red-900 dark:hover:text-red-300'

+ 129 - 48
frontend/src/components/AnnouncementsPanel.tsx

@@ -1,6 +1,6 @@
-import { useEffect, useRef, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
-import { ExternalLink, Megaphone, X } from 'lucide-react';
+import { ChevronDown, ChevronRight, ExternalLink, Megaphone, X } from 'lucide-react';
 import type { Announcement, AnnouncementLevel } from '../api/client';
 import { announcementText, isAllowedAnnouncementLink } from '../hooks/useAnnouncements';
 import { formatDateOnly } from '../utils/date';
@@ -16,30 +16,56 @@ interface AnnouncementsPanelProps {
   onClose: () => void;
   announcements: Announcement[];
   markRead: (id: string) => void;
+  // Expanded when the panel opens: the banner's "Read more" lands on its message.
+  focusId?: string | null;
 }
 
 /**
  * Slide-over list of announcements from the Bambuddy maintainers.
  *
- * Opening it marks everything in it read -- the dot and the banner go -- but the
- * ones that were unread keep a "New" chip until it closes, so the reader can
- * still tell what they came for.
+ * Each message is one row -- level, date, title -- and opens in place. Unread
+ * ones carry a dot and a "New" chip until opened; opening is what marks a
+ * message read, so the sidebar count is what is still unopened. Expired messages
+ * the feed keeps as history sit in a collapsed "Earlier" section below; they are
+ * never unread.
  */
-export function AnnouncementsPanel({ open, onClose, announcements, markRead }: AnnouncementsPanelProps) {
-  const { t, i18n } = useTranslation();
-  const [newIds, setNewIds] = useState<Set<string>>(() => new Set());
+export function AnnouncementsPanel({ open, onClose, announcements, markRead, focusId }: AnnouncementsPanelProps) {
+  const { t } = useTranslation();
+  const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
+  const [showEarlier, setShowEarlier] = useState(false);
   const closeRef = useRef<HTMLButtonElement>(null);
-  // The list at the moment of opening; later refetches don't re-mark anything.
   const latest = useRef({ announcements, markRead });
   latest.current = { announcements, markRead };
 
+  const expand = useCallback((id: string) => {
+    setExpanded((prev) => new Set(prev).add(id));
+    const a = latest.current.announcements.find((x) => x.id === id);
+    if (a && !a.read && !a.archived) latest.current.markRead(id);
+  }, []);
+
+  const toggle = useCallback(
+    (id: string) => {
+      if (expanded.has(id)) {
+        setExpanded((prev) => {
+          const next = new Set(prev);
+          next.delete(id);
+          return next;
+        });
+      } else {
+        expand(id);
+      }
+    },
+    [expanded, expand]
+  );
+
   useEffect(() => {
     if (!open) return;
-    const unreadIds = latest.current.announcements.filter((a) => !a.read).map((a) => a.id);
-    setNewIds(new Set(unreadIds));
-    unreadIds.forEach((id) => latest.current.markRead(id));
+    // Every visit starts collapsed, apart from the message the banner pointed at.
+    setExpanded(new Set());
+    setShowEarlier(false);
+    if (focusId) expand(focusId);
     closeRef.current?.focus();
-  }, [open]);
+  }, [open, focusId, expand]);
 
   useEffect(() => {
     if (!open) return;
@@ -52,6 +78,9 @@ export function AnnouncementsPanel({ open, onClose, announcements, markRead }: A
 
   if (!open) return null;
 
+  const current = announcements.filter((a) => !a.archived);
+  const earlier = announcements.filter((a) => a.archived);
+
   return (
     <>
       <div className="fixed inset-0 bg-black/60 z-[60]" onClick={onClose} aria-hidden="true" />
@@ -78,46 +107,34 @@ export function AnnouncementsPanel({ open, onClose, announcements, markRead }: A
         </header>
 
         <div className="flex-1 overflow-y-auto">
-          {announcements.length === 0 ? (
+          {current.length === 0 ? (
             <p className="p-6 text-center text-bambu-gray">{t('announcements.empty')}</p>
           ) : (
             <ul className="divide-y divide-bambu-dark-tertiary">
-              {announcements.map((a) => {
-                const text = announcementText(a, i18n.language);
-                const link = isAllowedAnnouncementLink(a.link_url) ? a.link_url : null;
-                return (
-                  <li key={a.id} className="px-5 py-4 space-y-2">
-                    <div className="flex items-center gap-2 flex-wrap text-xs">
-                      <span className={`px-2 py-0.5 rounded-full font-semibold ${LEVEL_CHIP[a.level]}`}>
-                        {t(`announcements.level.${a.level}`)}
-                      </span>
-                      {newIds.has(a.id) && (
-                        <span className="px-2 py-0.5 rounded-full font-semibold bg-bambu-green/20 text-bambu-green">
-                          {t('announcements.new')}
-                        </span>
-                      )}
-                      {a.published_at && (
-                        <span className="ml-auto text-bambu-gray">{formatDateOnly(a.published_at)}</span>
-                      )}
-                    </div>
-                    <h3 className="text-white font-semibold text-balance">{text.title}</h3>
-                    <p className="text-sm text-bambu-gray-light whitespace-pre-line break-words">{text.body}</p>
-                    {link && (
-                      <a
-                        href={link}
-                        target="_blank"
-                        rel="noopener noreferrer"
-                        className="inline-flex items-center gap-1 text-sm font-medium text-bambu-green hover:underline"
-                      >
-                        {text.link_label || t('announcements.readMore')}
-                        <ExternalLink className="w-3.5 h-3.5" />
-                      </a>
-                    )}
-                  </li>
-                );
-              })}
+              {current.map((a) => (
+                <AnnouncementItem key={a.id} announcement={a} expanded={expanded.has(a.id)} onToggle={toggle} />
+              ))}
             </ul>
           )}
+          {earlier.length > 0 && (
+            <section className="border-t border-bambu-dark-tertiary">
+              <button
+                onClick={() => setShowEarlier((v) => !v)}
+                aria-expanded={showEarlier}
+                className="w-full flex items-center gap-2 px-5 py-3 text-sm font-medium text-bambu-gray-light hover:text-white hover:bg-bambu-dark-tertiary transition-colors"
+              >
+                {showEarlier ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
+                {t('announcements.earlier', { count: earlier.length })}
+              </button>
+              {showEarlier && (
+                <ul className="divide-y divide-bambu-dark-tertiary opacity-80">
+                  {earlier.map((a) => (
+                    <AnnouncementItem key={a.id} announcement={a} expanded={expanded.has(a.id)} onToggle={toggle} />
+                  ))}
+                </ul>
+              )}
+            </section>
+          )}
         </div>
 
         <footer className="px-5 py-3 border-t border-bambu-dark-tertiary text-xs text-bambu-gray">
@@ -127,3 +144,67 @@ export function AnnouncementsPanel({ open, onClose, announcements, markRead }: A
     </>
   );
 }
+
+interface AnnouncementItemProps {
+  announcement: Announcement;
+  expanded: boolean;
+  onToggle: (id: string) => void;
+}
+
+function AnnouncementItem({ announcement: a, expanded, onToggle }: AnnouncementItemProps) {
+  const { t, i18n } = useTranslation();
+  const text = announcementText(a, i18n.language);
+  const link = isAllowedAnnouncementLink(a.link_url) ? a.link_url : null;
+  const unread = !a.read && !a.archived;
+  const bodyId = `announcement-body-${a.id}`;
+  return (
+    <li>
+      <button
+        onClick={() => onToggle(a.id)}
+        aria-expanded={expanded}
+        aria-controls={bodyId}
+        className="w-full text-left px-5 py-3 flex items-start gap-3 hover:bg-bambu-dark-tertiary transition-colors"
+      >
+        <span className="mt-1.5 w-2 h-2 flex-shrink-0 rounded-full" aria-hidden="true">
+          {unread && <span className="block w-2 h-2 rounded-full bg-bambu-green" />}
+        </span>
+        <span className="flex-1 min-w-0 space-y-1">
+          <span className="flex items-center gap-2 flex-wrap text-xs">
+            <span className={`px-2 py-0.5 rounded-full font-semibold ${LEVEL_CHIP[a.level]}`}>
+              {t(`announcements.level.${a.level}`)}
+            </span>
+            {unread && (
+              <span className="px-2 py-0.5 rounded-full font-semibold bg-bambu-green/20 text-bambu-green">
+                {t('announcements.new')}
+              </span>
+            )}
+            {a.published_at && <span className="ml-auto text-bambu-gray">{formatDateOnly(a.published_at)}</span>}
+          </span>
+          <span className={`block text-balance ${unread ? 'text-white font-semibold' : 'text-bambu-gray-light font-medium'}`}>
+            {text.title}
+          </span>
+        </span>
+        <ChevronDown
+          className={`w-4 h-4 mt-1 flex-shrink-0 text-bambu-gray transition-transform ${expanded ? 'rotate-180' : ''}`}
+          aria-hidden="true"
+        />
+      </button>
+      {expanded && (
+        <div id={bodyId} className="pl-10 pr-5 pb-4 space-y-2">
+          <p className="text-sm text-bambu-gray-light whitespace-pre-line break-words">{text.body}</p>
+          {link && (
+            <a
+              href={link}
+              target="_blank"
+              rel="noopener noreferrer"
+              className="inline-flex items-center gap-1 text-sm font-medium text-bambu-green hover:underline"
+            >
+              {text.link_label || t('announcements.readMore')}
+              <ExternalLink className="w-3.5 h-3.5" />
+            </a>
+          )}
+        </div>
+      )}
+    </li>
+  );
+}

+ 29 - 25
frontend/src/components/Layout.tsx

@@ -157,8 +157,10 @@ export function Layout() {
   const { announcements, unread: unreadAnnouncements, bannerItems, markRead: markAnnouncementRead } =
     useAnnouncements();
   const [announcementsOpen, setAnnouncementsOpen] = useState(false);
-  const openAnnouncements = useCallback(() => {
+  const [announcementFocus, setAnnouncementFocus] = useState<string | null>(null);
+  const openAnnouncements = useCallback((focusId?: string) => {
     setMobileDrawerOpen(false);
+    setAnnouncementFocus(focusId ?? null);
     setAnnouncementsOpen(true);
   }, []);
   const closeAnnouncements = useCallback(() => setAnnouncementsOpen(false), []);
@@ -746,29 +748,11 @@ export function Layout() {
         {/* Footer */}
         <div className="flex-shrink-0 p-2 border-t border-bambu-dark-tertiary">
           {isSidebarCompact || sidebarExpanded ? (
-            <div className="flex flex-col gap-2 px-2">
-              {/* Announcements: a row of its own above the icons, so it isn't one
-                  more glyph in a row people have stopped reading. Only while
-                  there is something to show. */}
-              {announcements.length > 0 && (
-                <button
-                  onClick={openAnnouncements}
-                  className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white text-sm"
-                >
-                  <Megaphone className="w-5 h-5 flex-shrink-0" />
-                  <span>{t('announcements.title')}</span>
-                  {unreadAnnouncements.length > 0 && (
-                    <span
-                      className="ml-auto min-w-[20px] h-5 px-1.5 flex items-center justify-center text-[11px] font-bold rounded-full bg-bambu-green text-white"
-                      aria-label={t('announcements.unread', { count: unreadAnnouncements.length })}
-                    >
-                      {unreadAnnouncements.length}
-                    </span>
-                  )}
-                </button>
-              )}
-              {/* Top row: icons */}
-              <div className="flex items-center justify-center gap-1 flex-wrap">
+            <div className="flex flex-col gap-2">
+              {/* Top row: icons. 32px each with no gap so seven fit the 239px
+                  of an expanded sidebar -- announcements, System, GitHub,
+                  shortcuts, theme, password, logout -- without wrapping. */}
+              <div className="flex items-center justify-center flex-wrap [&>a]:p-1.5 [&>button]:p-1.5 [&>span]:p-1.5 [&>div>button]:p-1.5">
                 {hasSwitchbarPlugs && (
                   <div className="relative">
                     <button
@@ -785,6 +769,25 @@ export function Layout() {
                     )}
                   </div>
                 )}
+                {announcements.length > 0 && (
+                  <button
+                    onClick={() => openAnnouncements()}
+                    className="relative p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
+                    title={t('announcements.title')}
+                    aria-label={
+                      unreadAnnouncements.length > 0
+                        ? t('announcements.unread', { count: unreadAnnouncements.length })
+                        : t('announcements.title')
+                    }
+                  >
+                    <Megaphone className="w-5 h-5" />
+                    {unreadAnnouncements.length > 0 && (
+                      <span className="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-1 flex items-center justify-center text-[10px] font-bold rounded-full bg-bambu-green text-white">
+                        {unreadAnnouncements.length}
+                      </span>
+                    )}
+                  </button>
+                )}
                 {hasPermission('system:read') ? (
                   <NavLink
                     to="/system"
@@ -892,7 +895,7 @@ export function Layout() {
               )}
               {announcements.length > 0 && (
                 <button
-                  onClick={openAnnouncements}
+                  onClick={() => openAnnouncements()}
                   className="relative p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
                   title={t('announcements.title')}
                   aria-label={
@@ -1065,6 +1068,7 @@ export function Layout() {
         onClose={closeAnnouncements}
         announcements={announcements}
         markRead={markAnnouncementRead}
+        focusId={announcementFocus}
       />
 
       <UnknownSpoolModal

+ 2 - 1
frontend/src/hooks/useAnnouncements.ts

@@ -69,7 +69,8 @@ export function useAnnouncements() {
   const { mutate } = markReadMutation;
   const markRead = useCallback((id: string) => mutate(id), [mutate]);
 
-  const unread = useMemo(() => announcements.filter((a) => !a.read), [announcements]);
+  // History is never unread: it was current once, and either read then or missed.
+  const unread = useMemo(() => announcements.filter((a) => !a.read && !a.archived), [announcements]);
 
   // What earns a banner: important or critical, not yet read. Most severe first,
   // then newest (the list already comes newest first).

+ 6 - 0
frontend/src/hooks/useWebSocket.ts

@@ -403,6 +403,12 @@ export function useWebSocket() {
         }));
         break;
 
+      case 'announcements_changed':
+        // The backend fetched a newer feed: re-read the list so the sidebar dot
+        // and the banner appear without a reload. The event says nothing itself.
+        debouncedInvalidate('announcements');
+        break;
+
       case 'inventory_changed':
         // Spool created/updated/deleted/archived/restored - refresh inventory across all tabs
         debouncedInvalidate('inventory-spools');

+ 1 - 0
frontend/src/i18n/locales/de.ts

@@ -37,6 +37,7 @@ export default {
     title: 'Ankündigungen',
     unread: 'Ungelesene Ankündigungen: {{count}}',
     empty: 'Derzeit keine Ankündigungen.',
+    earlier: 'Frühere ({{count}})',
     new: 'Neu',
     readMore: 'Mehr erfahren',
     readMoreCount: 'Mehr erfahren (+{{count}})',

+ 1 - 0
frontend/src/i18n/locales/en.ts

@@ -37,6 +37,7 @@ export default {
     title: 'Announcements',
     unread: 'Unread announcements: {{count}}',
     empty: 'No announcements right now.',
+    earlier: 'Earlier ({{count}})',
     new: 'New',
     readMore: 'Read more',
     readMoreCount: 'Read more (+{{count}})',

+ 1 - 0
frontend/src/i18n/locales/es.ts

@@ -37,6 +37,7 @@ export default {
     title: 'Anuncios',
     unread: 'Anuncios sin leer: {{count}}',
     empty: 'No hay anuncios por ahora.',
+    earlier: 'Anteriores ({{count}})',
     new: 'Nuevo',
     readMore: 'Leer más',
     readMoreCount: 'Leer más (+{{count}})',

+ 1 - 0
frontend/src/i18n/locales/fr.ts

@@ -37,6 +37,7 @@ export default {
     title: 'Annonces',
     unread: 'Annonces non lues : {{count}}',
     empty: 'Aucune annonce pour le moment.',
+    earlier: 'Précédentes ({{count}})',
     new: 'Nouveau',
     readMore: 'En savoir plus',
     readMoreCount: 'En savoir plus (+{{count}})',

+ 1 - 0
frontend/src/i18n/locales/it.ts

@@ -37,6 +37,7 @@ export default {
     title: 'Annunci',
     unread: 'Annunci non letti: {{count}}',
     empty: 'Nessun annuncio al momento.',
+    earlier: 'Precedenti ({{count}})',
     new: 'Nuovo',
     readMore: 'Scopri di più',
     readMoreCount: 'Scopri di più (+{{count}})',

+ 1 - 0
frontend/src/i18n/locales/ja.ts

@@ -37,6 +37,7 @@ export default {
     title: 'お知らせ',
     unread: '未読のお知らせ: {{count}}',
     empty: '現在お知らせはありません。',
+    earlier: '過去のお知らせ ({{count}})',
     new: '新着',
     readMore: '詳細を見る',
     readMoreCount: '詳細を見る (+{{count}})',

+ 1 - 0
frontend/src/i18n/locales/ko.ts

@@ -34,6 +34,7 @@ export default {
     title: '공지사항',
     unread: '읽지 않은 공지사항: {{count}}',
     empty: '현재 공지사항이 없습니다.',
+    earlier: '이전 공지 ({{count}})',
     new: '새 소식',
     readMore: '자세히 보기',
     readMoreCount: '자세히 보기 (+{{count}})',

+ 1 - 0
frontend/src/i18n/locales/nl.ts

@@ -37,6 +37,7 @@ export default {
     title: 'Aankondigingen',
     unread: 'Ongelezen aankondigingen: {{count}}',
     empty: 'Op dit moment geen aankondigingen.',
+    earlier: 'Eerder ({{count}})',
     new: 'Nieuw',
     readMore: 'Meer lezen',
     readMoreCount: 'Meer lezen (+{{count}})',

+ 1 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -37,6 +37,7 @@ export default {
     title: 'Anúncios',
     unread: 'Anúncios não lidos: {{count}}',
     empty: 'Nenhum anúncio no momento.',
+    earlier: 'Anteriores ({{count}})',
     new: 'Novo',
     readMore: 'Saiba mais',
     readMoreCount: 'Saiba mais (+{{count}})',

+ 1 - 0
frontend/src/i18n/locales/ru.ts

@@ -34,6 +34,7 @@ export default {
     title: 'Объявления',
     unread: 'Непрочитанные объявления: {{count}}',
     empty: 'Сейчас объявлений нет.',
+    earlier: 'Ранее ({{count}})',
     new: 'Новое',
     readMore: 'Подробнее',
     readMoreCount: 'Подробнее (+{{count}})',

+ 1 - 0
frontend/src/i18n/locales/sv.ts

@@ -37,6 +37,7 @@ export default {
     title: 'Meddelanden',
     unread: 'Olästa meddelanden: {{count}}',
     empty: 'Inga meddelanden just nu.',
+    earlier: 'Tidigare ({{count}})',
     new: 'Ny',
     readMore: 'Läs mer',
     readMoreCount: 'Läs mer (+{{count}})',

+ 1 - 0
frontend/src/i18n/locales/tr.ts

@@ -37,6 +37,7 @@ export default {
     title: 'Duyurular',
     unread: 'Okunmamış duyurular: {{count}}',
     empty: 'Şu anda duyuru yok.',
+    earlier: 'Önceki ({{count}})',
     new: 'Yeni',
     readMore: 'Devamını oku',
     readMoreCount: 'Devamını oku (+{{count}})',

+ 1 - 0
frontend/src/i18n/locales/uk.ts

@@ -37,6 +37,7 @@ export default {
     title: 'Оголошення',
     unread: 'Непрочитані оголошення: {{count}}',
     empty: 'Наразі оголошень немає.',
+    earlier: 'Раніше ({{count}})',
     new: 'Нове',
     readMore: 'Докладніше',
     readMoreCount: 'Докладніше (+{{count}})',

+ 1 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -37,6 +37,7 @@ export default {
     title: '公告',
     unread: '未读公告:{{count}}',
     empty: '暂无公告。',
+    earlier: '更早 ({{count}})',
     new: '新',
     readMore: '了解更多',
     readMoreCount: '了解更多 (+{{count}})',

+ 1 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -37,6 +37,7 @@ export default {
     title: '公告',
     unread: '未讀公告:{{count}}',
     empty: '目前沒有公告。',
+    earlier: '更早 ({{count}})',
     new: '新',
     readMore: '了解更多',
     readMoreCount: '了解更多 (+{{count}})',

File diff suppressed because it is too large
+ 0 - 0
static/assets/ImagePreviewModal-gsnEQ0Oa.js


File diff suppressed because it is too large
+ 0 - 1
static/assets/PdfPreviewModal-VeiGrj3M.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpreadsheetPreviewModal-Ws1DaBxm.js


File diff suppressed because it is too large
+ 0 - 1
static/assets/index-CvbMEZlt.css


File diff suppressed because it is too large
+ 1 - 1
static/assets/index-XSkcXiLG.js


File diff suppressed because it is too large
+ 1 - 0
static/assets/index-b50e2nk4.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/pdf-vgFNYDS8.js


+ 2 - 2
static/index.html

@@ -26,9 +26,9 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CceJ9Gq0.js"></script>
+    <script type="module" crossorigin src="/assets/index-XSkcXiLG.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
-    <link rel="stylesheet" crossorigin href="/assets/index-CvbMEZlt.css">
+    <link rel="stylesheet" crossorigin href="/assets/index-b50e2nk4.css">
   </head>
   <body>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff