mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
feat: update announcement system
This commit is contained in:
parent
b3fc0ee6b3
commit
68df46cf6b
8 changed files with 128 additions and 4 deletions
|
|
@ -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.
|
||||
|
|
|
|||
BIN
meshchatx.rsm
BIN
meshchatx.rsm
Binary file not shown.
|
|
@ -7216,6 +7216,7 @@ class ReticulumMeshChat:
|
|||
{
|
||||
"type": "announced",
|
||||
"identity_hash": ctx.identity_hash,
|
||||
"last_announced_at": ctx.config.last_announced_at.get(),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": [],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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" }) },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue