From 68df46cf6bb1e3c4f08c855e1ea97e1aa6b722d8 Mon Sep 17 00:00:00 2001 From: Ivan Date: Thu, 13 Aug 2026 18:40:55 -0500 Subject: [PATCH] feat: update announcement system --- CHANGELOG.md | 1 + meshchatx.rsm | Bin 193172 -> 193172 bytes meshchatx/meshchat.py | 1 + meshchatx/src/frontend/components/App.vue | 23 ++++++++- tests/backend/test_auto_announce_schedule.py | 18 +++++++ tests/backend/ws_json_contract_schemas.py | 14 +++++- tests/frontend/AppAutoAnnounce.test.js | 47 ++++++++++++++++++ .../AppSidebarIdentityAnnounce.test.js | 28 +++++++++++ 8 files changed, 128 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bad8c243..804fb6a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ All notable changes to this project will be documented in this file. ### Fixed +- **Sidebar last announced**: Relative time on the identity footer now recomputes on the 1-second shell poll. Auto-announce WebSocket events include `last_announced_at`, so the stamp updates without a page reload. - **Docker frontend build**: Copy `scripts/vite-dx.mjs` into the Node stage. `vite.config.js` imports it for Vue DevTools gates, so `vite build` failed with UNRESOLVED_IMPORT when the file was missing from the image. - **Map KMZ import**: ArcGIS KMZ files (including [GhostMaps](https://github.com/s2underground/GhostMaps) ATAK exports) failed in two ways. An unused .xsl balloon stylesheet next to doc.kml was treated as an unsafe zip entry and showed "Could not read vector file." HTML balloon text inside CDATA left a stray CDATA closer after sanitizing, so OpenLayers parsed zero features. Sidecars that are not KML or raster icons are skipped. CDATA HTML is flattened to escaped plain text. Placemarks and zip-local PNG/JPEG/GIF/WebP icons still import. - **Collapsed sidebar**: Icons in the 64px app rail (nav, More, collapse chevron, identity chip, announce) and the Messages/Nomad collapse chevrons sit on the vertical center line. Collapsed nav links no longer keep the expanded right-margin offset. diff --git a/meshchatx.rsm b/meshchatx.rsm index 1f4d40256ac1d49d2c3dcf135104340ac61e4a95..1db24907de3c4e66ac955f8bcd61b7c187c0e503 100644 GIT binary patch delta 625 zcmWNOJ!lp%5QgoiQlS)7M5RNk(8-}*?vhKg*iVoOU33w2=#b=cP=8S9(y0!0aB-*> zii3+fbn%aZpSUPENI|d`#KjgJsyM2kA`WH>M~>&7dv9@TU~y}p`*863=8g3S<4;E> zx1WD~^y@O6J1{l6nZee%ma`}}nO@Uva(b7T0u|IOs3>A^UE zX4^vd;GsK{yJvQO`u+F*+2xxr?&b@5dg1M-HIAr_wyKqF0dHf5T)}flRRP2iAbDoj1UW%w z(`vCf1;?`V`FP*Zt(Wfx<~LS*8>@YGWZXnhjX6N7)+<|vjGFn?6x4zBUu4%htCB&Ld7Khn8muz&gR&h{$N*Mx&=$f|+%_v7^ zV^NBewjlMc<<6hMZiyZnsfcHag;;CxPK{N})(9F9D_RIiqk&Q_dMB)cQiWNATQW19 zeZ93Sox`23K|?-gX)48oL_;RBcxhpCOpb#$YFdg~wH>mw>B delta 625 zcmWlW%S+c$5XJlbdVokl5E3rZ9-E%rxifPg1oegb12PEQNZiNV=wSpwxCn!6kxNPV z(V~S!L5qYi_^qNa65LgbAi+pN!bJrUVGz`Hdzd-rGiRn(d#6`>oB5~bE|yKS`N4~u zKL(o%BTp_beBXNe!w$atWo>2KzWZN)A3imD_4WIqo=5+Vk59fid+^BT^Uph@A1{3y znrZcInQC?qP9B$OI07(7|y0N5i3Q=rTp80bIU_#89D0z{g$jzu{ek*jqE zoq^dKm4ekaVD`mDQymovaZ)iWW!&6&`YR_aVx}BQRt(vhkT+J7*N{+LFhLgx5i(iu zRw*qW03-Wc13i~cm}vFid-bk&VtKKMQtA*=rBXp+>g-%^^>xcy>(f|c5+~AZ5JCzpU?fPeDH(DZU13m#NVF*MsdiWu zQkjfbp=Pj_?w#0P-`*4oNvGl~ie)M~V<<(EvIJNZ0;Lkgd7TR~W)Z { this.updateTelephoneStatus(); this.updatePropagationNodeStatus(); + this.lastAnnouncedTick += 1; }, applyBackgroundPollInterval(1000, prefs) ); @@ -1698,8 +1701,8 @@ export default { keyboard_shortcuts: (json) => { KeyboardShortcuts.setShortcuts(json.shortcuts); }, - announced: () => { - this.getConfig(); + announced: (json) => { + this.applyAnnouncedEvent(json); }, telephone_ringing: (json) => { if (this.config?.do_not_disturb_enabled) { @@ -1924,6 +1927,22 @@ export default { console.log(e); } }, + applyAnnouncedEvent(json) { + const identityHash = typeof json?.identity_hash === "string" ? json.identity_hash : ""; + if (identityHash && this.config?.identity_hash && identityHash !== this.config.identity_hash) { + return; + } + const raw = json?.last_announced_at; + if (raw != null && raw !== "") { + const ts = Number(raw); + if (this.config && Number.isFinite(ts)) { + mergeGlobalConfig({ last_announced_at: ts }); + this.config = { ...this.config, last_announced_at: ts }; + return; + } + } + this.getConfig(); + }, async getBlockedDestinations() { try { const response = await window.api.get("/api/v1/blocked-destinations"); diff --git a/tests/backend/test_auto_announce_schedule.py b/tests/backend/test_auto_announce_schedule.py index 64788285..b67ac67e 100644 --- a/tests/backend/test_auto_announce_schedule.py +++ b/tests/backend/test_auto_announce_schedule.py @@ -96,3 +96,21 @@ def test_last_announced_at_config_roundtrip_db(tmp_path): database.close() if os.path.exists(db_path): os.remove(db_path) + + +@pytest.mark.asyncio +async def test_send_announced_includes_last_announced_at(mock_app): + from unittest.mock import AsyncMock + import json + + ts = 1_700_000_000 + mock_app.current_context.config.last_announced_at.set(ts) + mock_app.websocket_broadcast = AsyncMock() + + await mock_app.send_announced_to_websocket_clients() + + mock_app.websocket_broadcast.assert_awaited_once() + payload = json.loads(mock_app.websocket_broadcast.await_args.args[0]) + assert payload["type"] == "announced" + assert payload["last_announced_at"] == ts + assert payload["identity_hash"] == mock_app.current_context.identity_hash diff --git a/tests/backend/ws_json_contract_schemas.py b/tests/backend/ws_json_contract_schemas.py index 7a95f304..f0e05f04 100644 --- a/tests/backend/ws_json_contract_schemas.py +++ b/tests/backend/ws_json_contract_schemas.py @@ -58,7 +58,13 @@ WS_MESSAGE_SCHEMAS: dict[str, dict] = { required=["config"], properties={"config": _WS_OBJECT}, ), - "announced": _ws_type("announced"), + "announced": _ws_type( + "announced", + properties={ + "identity_hash": _WS_STRING, + "last_announced_at": {"type": ["integer", "null"]}, + }, + ), "blocked_destinations": _ws_type( "blocked_destinations", required=["blocked_destinations"], @@ -282,7 +288,11 @@ WS_MESSAGE_SAMPLES: dict[str, dict] = { }, "config.set": {"type": "config.set", "config": {"display_name": "Test"}}, "config": {"type": "config", "config": {"display_name": "Test"}}, - "announced": {"type": "announced"}, + "announced": { + "type": "announced", + "identity_hash": "abc123", + "last_announced_at": 1700000000, + }, "blocked_destinations": { "type": "blocked_destinations", "blocked_destinations": [], diff --git a/tests/frontend/AppAutoAnnounce.test.js b/tests/frontend/AppAutoAnnounce.test.js index 5d1fec24..a64e2893 100644 --- a/tests/frontend/AppAutoAnnounce.test.js +++ b/tests/frontend/AppAutoAnnounce.test.js @@ -99,4 +99,51 @@ describe("App.vue sidebar announce and auto-announce interval", () => { auto_announce_interval_seconds: 3600, }); }); + + it("applyAnnouncedEvent writes last_announced_at without waiting for config GET", () => { + const ctx = { + config: { identity_hash: "h1", last_announced_at: 100 }, + getConfig: vi.fn(), + }; + + App.methods.applyAnnouncedEvent.call(ctx, { + type: "announced", + identity_hash: "h1", + last_announced_at: 1_700_000_000, + }); + + expect(ctx.config.last_announced_at).toBe(1_700_000_000); + expect(ctx.getConfig).not.toHaveBeenCalled(); + }); + + it("applyAnnouncedEvent ignores a stamp from another identity", () => { + const ctx = { + config: { identity_hash: "h1", last_announced_at: 100 }, + getConfig: vi.fn(), + }; + + App.methods.applyAnnouncedEvent.call(ctx, { + type: "announced", + identity_hash: "h2", + last_announced_at: 1_700_000_000, + }); + + expect(ctx.config.last_announced_at).toBe(100); + expect(ctx.getConfig).not.toHaveBeenCalled(); + }); + + it("applyAnnouncedEvent falls back to getConfig when stamp is missing", () => { + const ctx = { + config: { identity_hash: "h1", last_announced_at: 100 }, + getConfig: vi.fn(), + }; + + App.methods.applyAnnouncedEvent.call(ctx, { + type: "announced", + identity_hash: "h1", + }); + + expect(ctx.config.last_announced_at).toBe(100); + expect(ctx.getConfig).toHaveBeenCalledTimes(1); + }); }); diff --git a/tests/frontend/AppSidebarIdentityAnnounce.test.js b/tests/frontend/AppSidebarIdentityAnnounce.test.js index f5f959dc..9bfceeed 100644 --- a/tests/frontend/AppSidebarIdentityAnnounce.test.js +++ b/tests/frontend/AppSidebarIdentityAnnounce.test.js @@ -321,6 +321,34 @@ describe("App.vue sidebar identity label and announce control", () => { expect(radio.element.parentElement).not.toBe(announced.element.parentElement); }); + it("last announced relative time updates when the shell tick fires", async () => { + wrapper = makeMountedApp(); + await readyShell(wrapper.vm.$router); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-13T12:00:00Z")); + wrapper.vm.config = { + ...wrapper.vm.config, + last_announced_at: Math.floor(Date.now() / 1000) - 125, + }; + wrapper.vm.lastAnnouncedTick += 1; + await wrapper.vm.$nextTick(); + const announced = wrapper.find("[data-testid=sidebar-last-announced]"); + expect(announced.text()).toMatch(/2 minutes/i); + + vi.setSystemTime(new Date("2026-08-13T12:01:00Z")); + await wrapper.vm.$nextTick(); + expect(wrapper.find("[data-testid=sidebar-last-announced]").text()).toMatch(/2 minutes/i); + + wrapper.vm.lastAnnouncedTick += 1; + await wrapper.vm.$nextTick(); + expect(wrapper.find("[data-testid=sidebar-last-announced]").text()).toMatch(/3 minutes/i); + + const tickBefore = wrapper.vm.lastAnnouncedTick; + wrapper.vm.startShellPollIntervals(); + await vi.advanceTimersByTimeAsync(1000); + expect(wrapper.vm.lastAnnouncedTick).toBe(tickBefore + 1); + }); + it("saves display name on Enter without a save button", async () => { axiosMock.patch = vi.fn().mockResolvedValue({ data: { config: makeConfig({ display_name: "Renamed Peer" }) },