feat: update announcement system

This commit is contained in:
Ivan 2026-08-13 18:40:55 -05:00
parent b3fc0ee6b3
commit 68df46cf6b
No known key found for this signature in database
8 changed files with 128 additions and 4 deletions

View file

@ -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.

Binary file not shown.

View file

@ -7216,6 +7216,7 @@ class ReticulumMeshChat:
{
"type": "announced",
"identity_hash": ctx.identity_hash,
"last_announced_at": ctx.config.last_announced_at.get(),
},
),
)

View file

@ -586,6 +586,7 @@ export default {
reloadInterval: null,
appInfoInterval: null,
unreadCountInterval: null,
lastAnnouncedTick: 0,
isSidebarOpen: false,
isSidebarCollapsed: false,
@ -734,6 +735,7 @@ export default {
if (!this.config?.last_announced_at) {
return "";
}
void this.lastAnnouncedTick;
return this.formatSecondsAgo(this.config.last_announced_at);
},
isSyncingPropagationNode() {
@ -1120,6 +1122,7 @@ export default {
() => {
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");

View file

@ -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

View file

@ -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": [],

View file

@ -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);
});
});

View file

@ -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" }) },