Просмотр исходного кода

Keep the Announcements entry for whoever may see announcements

maziggy 1 день назад
Родитель
Сommit
a81ac2cfb5

+ 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. 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.
+- **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. An **Announcements** entry sits at the bottom of the sidebar above the System icon for everyone who may see them, 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.

+ 12 - 7
backend/app/api/routes/announcements.py

@@ -2,9 +2,10 @@
 
 Shown to administrators, and to every signed-in user when the
 ``announcements_all_users`` setting is on. With authentication off whoever opens
-Bambuddy runs it, so they see them. Anyone else gets an empty list rather than a
-403: the sidebar entry and the banner are hidden on "nothing to show", which is
-the same answer for "nothing published" and "not for you".
+Bambuddy runs it, so they see them. Anyone else gets ``visible: false`` and an
+empty list rather than a 403. ``visible`` is separate from the list because the
+sidebar entry is there for whoever may see announcements, also while nothing is
+published, and hidden for everyone else.
 """
 
 from fastapi import APIRouter, Depends, HTTPException, status
@@ -40,11 +41,15 @@ async def _may_see(db: AsyncSession, user: User | None) -> bool:
 async def list_announcements(
     db: AsyncSession = Depends(get_db),
     current_user: User | None = Depends(require_auth_if_enabled),
-) -> list[dict]:
-    """Live announcements for this user, newest first, with their read state."""
+) -> dict:
+    """Whether this user may see announcements, and the live ones newest first,
+    with their read state."""
     if not await _may_see(db, current_user):
-        return []
-    return await service.list_for(db, current_user.id if current_user else None)
+        return {"visible": False, "announcements": []}
+    return {
+        "visible": True,
+        "announcements": await service.list_for(db, current_user.id if current_user else None),
+    }
 
 
 @router.post("/{public_id}/read", status_code=status.HTTP_204_NO_CONTENT)

+ 39 - 11
backend/tests/integration/test_announcements_api.py

@@ -2,8 +2,9 @@
 
 Administrators see them; other signed-in users only when
 ``announcements_all_users`` is on. With authentication off, the person running
-Bambuddy sees them. Anyone else gets an empty list, which hides the sidebar entry
-and the banner the same way "nothing published" does.
+Bambuddy sees them. Anyone else gets ``visible: false`` and an empty list. Those
+who may see them get ``visible: true`` even with nothing published, because the
+sidebar entry is there for them either way.
 """
 
 import json
@@ -36,6 +37,16 @@ async def _setting(db, key: str, value: str) -> None:
     await db.commit()
 
 
+async def _get(client: AsyncClient, headers: dict | None = None) -> dict:
+    r = await client.get(URL, headers=headers)
+    assert r.status_code == 200, r.text
+    return r.json()
+
+
+async def _items(client: AsyncClient, headers: dict | None = None) -> list[dict]:
+    return (await _get(client, headers))["announcements"]
+
+
 async def _admin(client: AsyncClient, username: str) -> dict:
     await client.post(
         "/api/v1/auth/setup",
@@ -66,9 +77,9 @@ class TestAuthOff:
     @pytest.mark.asyncio
     async def test_listed_with_every_language_and_unread(self, async_client: AsyncClient, db_session):
         await _seed(db_session, "a1")
-        r = await async_client.get(URL)
-        assert r.status_code == 200
-        [item] = r.json()
+        body = await _get(async_client)
+        assert body["visible"] is True
+        [item] = body["announcements"]
         assert item["id"] == "a1" and item["level"] == "important" and item["read"] is False
         assert set(item["texts"]) == {"en", "de"}
 
@@ -76,7 +87,13 @@ class TestAuthOff:
     async def test_read_sticks(self, async_client: AsyncClient, db_session):
         await _seed(db_session, "a1")
         assert (await async_client.post(f"{URL}/a1/read")).status_code == 204
-        assert (await async_client.get(URL)).json()[0]["read"] is True
+        assert (await _items(async_client))[0]["read"] is True
+
+    @pytest.mark.asyncio
+    async def test_visible_with_nothing_published(self, async_client: AsyncClient):
+        # The sidebar entry stays for whoever may see announcements, so an empty
+        # inbox has to say "visible", not look like "not for you".
+        assert await _get(async_client) == {"visible": True, "announcements": []}
 
     @pytest.mark.asyncio
     async def test_unknown_id_is_404(self, async_client: AsyncClient):
@@ -86,7 +103,7 @@ class TestAuthOff:
     async def test_switched_off_shows_nothing(self, async_client: AsyncClient, db_session):
         await _seed(db_session, "a1")
         await _setting(db_session, "announcements_enabled", "false")
-        assert (await async_client.get(URL)).json() == []
+        assert await _get(async_client) == {"visible": False, "announcements": []}
         assert (await async_client.post(f"{URL}/a1/read")).status_code == 404
 
 
@@ -102,8 +119,10 @@ class TestAuthOn:
         await _seed(db_session, "a1")
         admin = await _admin(async_client, "annadmin1")
         user = await _user(async_client, admin, "annuser1")
-        assert [i["id"] for i in (await async_client.get(URL, headers=admin)).json()] == ["a1"]
-        assert (await async_client.get(URL, headers=user)).json() == []
+        admin_view = await _get(async_client, admin)
+        assert admin_view["visible"] is True
+        assert [i["id"] for i in admin_view["announcements"]] == ["a1"]
+        assert await _get(async_client, user) == {"visible": False, "announcements": []}
         assert (await async_client.post(f"{URL}/a1/read", headers=user)).status_code == 404
 
     @pytest.mark.asyncio
@@ -114,8 +133,17 @@ class TestAuthOn:
         await _setting(db_session, "announcements_all_users", "true")
 
         assert (await async_client.post(f"{URL}/a1/read", headers=user)).status_code == 204
-        assert (await async_client.get(URL, headers=user)).json()[0]["read"] is True
-        assert (await async_client.get(URL, headers=admin)).json()[0]["read"] is False
+        user_view = await _get(async_client, user)
+        assert user_view["visible"] is True
+        assert user_view["announcements"][0]["read"] is True
+        assert (await _items(async_client, admin))[0]["read"] is False
+
+    @pytest.mark.asyncio
+    async def test_admin_entry_visible_with_nothing_published(self, async_client: AsyncClient):
+        admin = await _admin(async_client, "annadmin3")
+        user = await _user(async_client, admin, "annuser3")
+        assert await _get(async_client, admin) == {"visible": True, "announcements": []}
+        assert (await _get(async_client, user))["visible"] is False
 
 
 @pytest.mark.integration

+ 29 - 7
frontend/src/__tests__/components/Announcements.test.tsx

@@ -184,16 +184,35 @@ describe('Layout with announcements', () => {
     );
   });
 
-  it('shows no entry and no banner when there is nothing', async () => {
+  it('keeps the entry with nothing published, opening an empty list', async () => {
+    // The entry is where announcements live, not a notice that one arrived: it
+    // stays for whoever may see them, with no count and no banner.
+    render(<Layout />);
+    const entry = await screen.findByRole('button', { name: /announcements/i });
+    expect(within(entry).queryByText(/\d/)).not.toBeInTheDocument();
+    expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+    expect(screen.queryByRole('status')).not.toBeInTheDocument();
+    fireEvent.click(entry);
+    const dialog = await screen.findByRole('dialog');
+    expect(within(dialog).getByText('No announcements right now.')).toBeInTheDocument();
+  });
+
+  it('shows no entry for someone who may not see announcements', async () => {
+    server.use(
+      http.get('/api/v1/announcements', () => HttpResponse.json({ visible: false, announcements: [] }))
+    );
     render(<Layout />);
     await waitFor(() => expect(screen.getAllByTitle(/System/).length).toBeGreaterThan(0));
-    expect(screen.queryByText('Announcements')).not.toBeInTheDocument();
+    expect(screen.queryByRole('button', { name: /announcements/i })).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 })])
+        HttpResponse.json({
+          visible: true,
+          announcements: [announcement({ id: 'old', level: 'critical', archived: true })],
+        })
       )
     );
     render(<Layout />);
@@ -206,10 +225,13 @@ describe('Layout with announcements', () => {
     let reads: string[] = [];
     server.use(
       http.get('/api/v1/announcements', () =>
-        HttpResponse.json([
-          announcement({ id: 'imp', level: 'important', texts: { en: { title: 'Breaking change in 2.0', body: 'b' } } }),
-          announcement({ id: 'inf', level: 'info', texts: { en: { title: 'Testers wanted', body: 'b' } } }),
-        ])
+        HttpResponse.json({
+          visible: true,
+          announcements: [
+            announcement({ id: 'imp', level: 'important', texts: { en: { title: 'Breaking change in 2.0', body: 'b' } } }),
+            announcement({ id: 'inf', level: 'info', texts: { en: { title: 'Testers wanted', body: 'b' } } }),
+          ],
+        })
       ),
       http.post('/api/v1/announcements/:id/read', ({ params }) => {
         reads = [...reads, String(params.id)];

+ 1 - 1
frontend/src/__tests__/mocks/handlers.ts

@@ -385,7 +385,7 @@ export const handlers = [
 
   // Announcements from the maintainers: none by default.
   http.get('/api/v1/announcements', () => {
-    return HttpResponse.json([]);
+    return HttpResponse.json({ visible: true, announcements: [] });
   }),
 
   http.get('/api/v1/version', () => {

+ 9 - 1
frontend/src/api/client.ts

@@ -9221,8 +9221,16 @@ export interface Announcement {
   read: boolean;
 }
 
+// `visible` says whether this user may see announcements at all (switched on,
+// and admin or "show to all users"); it keeps the sidebar entry while nothing
+// is published.
+export interface AnnouncementList {
+  visible: boolean;
+  announcements: Announcement[];
+}
+
 export const announcementsApi = {
-  list: () => request<Announcement[]>('/announcements'),
+  list: () => request<AnnouncementList>('/announcements'),
   markRead: (id: string) =>
     request<void>(`/announcements/${encodeURIComponent(id)}/read`, { method: 'POST' }),
 };

+ 11 - 5
frontend/src/components/Layout.tsx

@@ -153,9 +153,15 @@ export function Layout() {
   });
 
   // Announcements from the Bambuddy maintainers: the sidebar entry above System,
-  // the slide-over list, and the banner for unread important/critical ones.
-  const { announcements, unread: unreadAnnouncements, bannerItems, markRead: markAnnouncementRead } =
-    useAnnouncements();
+  // the slide-over list, and the banner for unread important/critical ones. The
+  // entry is there for whoever may see announcements, also with none published.
+  const {
+    visible: announcementsVisible,
+    announcements,
+    unread: unreadAnnouncements,
+    bannerItems,
+    markRead: markAnnouncementRead,
+  } = useAnnouncements();
   const [announcementsOpen, setAnnouncementsOpen] = useState(false);
   const [announcementFocus, setAnnouncementFocus] = useState<string | null>(null);
   const openAnnouncements = useCallback((focusId?: string) => {
@@ -769,7 +775,7 @@ export function Layout() {
                     )}
                   </div>
                 )}
-                {announcements.length > 0 && (
+                {announcementsVisible && (
                   <button
                     onClick={() => openAnnouncements()}
                     className="relative p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
@@ -893,7 +899,7 @@ export function Layout() {
                   )}
                 </div>
               )}
-              {announcements.length > 0 && (
+              {announcementsVisible && (
                 <button
                   onClick={() => openAnnouncements()}
                   className="relative p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"

+ 9 - 8
frontend/src/hooks/useAnnouncements.ts

@@ -1,6 +1,6 @@
 import { useCallback, useMemo } from 'react';
 import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
-import { announcementsApi, type Announcement, type AnnouncementText } from '../api/client';
+import { announcementsApi, type Announcement, type AnnouncementList, type AnnouncementText } from '../api/client';
 import { useAuth } from '../contexts/AuthContext';
 
 // Hosts a message may link to. The backend already drops any other link; this
@@ -38,9 +38,9 @@ const SEVERITY: Record<Announcement['level'], number> = { critical: 2, important
 /**
  * Announcements from the Bambuddy maintainers, for whoever may see them.
  *
- * The backend answers with an empty list for anyone who may not (and when the
- * install has them switched off), so an empty list is the one signal for
- * "show nothing" -- no separate permission check here.
+ * The backend says whether this user may see them at all (`visible`: switched
+ * on, and admin or "show to all users"), so there is no separate permission
+ * check here. `visible` with an empty list is an empty inbox, not "hide".
  */
 export function useAnnouncements() {
   const { authEnabled, user, loading } = useAuth();
@@ -55,13 +55,14 @@ export function useAnnouncements() {
     // to GitHub every few hours.
     refetchInterval: 10 * 60 * 1000,
   });
-  const announcements = useMemo(() => data ?? [], [data]);
+  const visible = data?.visible ?? false;
+  const announcements = useMemo(() => data?.announcements ?? [], [data]);
 
   const markReadMutation = useMutation({
     mutationFn: announcementsApi.markRead,
     onMutate: (id: string) => {
-      queryClient.setQueryData<Announcement[]>(['announcements'], (old) =>
-        old?.map((a) => (a.id === id ? { ...a, read: true } : a))
+      queryClient.setQueryData<AnnouncementList>(['announcements'], (old) =>
+        old && { ...old, announcements: old.announcements.map((a) => (a.id === id ? { ...a, read: true } : a)) }
       );
     },
     onError: () => queryClient.invalidateQueries({ queryKey: ['announcements'] }),
@@ -82,5 +83,5 @@ export function useAnnouncements() {
     [unread]
   );
 
-  return { announcements, unread, bannerItems, markRead };
+  return { visible, announcements, unread, bannerItems, markRead };
 }

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ImagePreviewModal-C-WFsOtq.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 1
static/assets/PdfPreviewModal-AgMs-G9b.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SpreadsheetPreviewModal-D3re3Dlf.js


Разница между файлами не показана из-за своего большого размера
+ 1 - 1
static/assets/index-Bc3Y2a4o.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/pdf-4oiL9bye.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-BTPGBrZ0.js"></script>
+    <script type="module" crossorigin src="/assets/index-Bc3Y2a4o.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
     <link rel="stylesheet" crossorigin href="/assets/index-CyW_3l56.css">
   </head>

Некоторые файлы не были показаны из-за большого количества измененных файлов