From 2d80020bb15b6202dfa5f4a9721e574703875a7d Mon Sep 17 00:00:00 2001
From: ann0see <20726856+ann0see@users.noreply.github.com>
Date: Sun, 9 Aug 2026 22:10:54 +0200
Subject: [PATCH] Add structured chat messages with channel-scoped client
rendering
Introduces chat message type 37 carrying semantic chat data (channel id, timestamp, sender name, text) alongside the legacy message 18, negotiated via new REQ_CHAT_TEXT_SUPPORT / CHAT_TEXT_SUPPORTED messages (38/39). The server fans out structured chat to all clients and exposes it over JSON-RPC (jamulusserver/chatMessageReceived, jamulusclient/chatTextReceived). The client renders messages client-side with per-channel colors and safe linkification, escaping user text before any HTML interpretation.
---
docs/JSON-RPC.md | 15 +-
docs/design-chat-redesign.md | 224 +++++++++++
src/channel.cpp | 7 +
src/channel.h | 13 +-
src/chatdlg.cpp | 90 ++++-
src/chatdlg.h | 6 +
src/chatmessage.h | 43 +++
src/client.cpp | 11 +
src/client.h | 3 +
src/clientdlg.cpp | 17 +
src/clientdlg.h | 1 +
src/clientrpc.cpp | 14 +-
src/global.h | 7 +
src/protocol.cpp | 106 +++++
src/protocol.h | 12 +
src/server.cpp | 73 +++-
src/server.h | 6 +-
src/serverrpc.cpp | 19 +-
src/util.cpp | 32 ++
src/util.h | 6 +
tests/chatprotocol/chatprotocol.pro | 25 ++
tests/chatprotocol/tst_chatprotocol.cpp | 490 ++++++++++++++++++++++++
tools/generate_json_rpc_docs.py | 2 +-
23 files changed, 1156 insertions(+), 66 deletions(-)
create mode 100644 docs/design-chat-redesign.md
create mode 100644 src/chatmessage.h
create mode 100644 tests/chatprotocol/chatprotocol.pro
create mode 100644 tests/chatprotocol/tst_chatprotocol.cpp
diff --git a/docs/JSON-RPC.md b/docs/JSON-RPC.md
index fbcd2d641d..db6d2240e9 100644
--- a/docs/JSON-RPC.md
+++ b/docs/JSON-RPC.md
@@ -74,7 +74,7 @@ The request must be sent as a single line of JSON-encoded data, followed by a ne
Jamulus will also send **notifications** to the consumer:
```json
-{"jsonrpc":"2.0","method":"jamulusclient/chatTextReceived","params":{"text":"(01:23:45 AM) user test"}}
+{"jsonrpc":"2.0","method":"jamulusclient/chatTextReceived","params":{"channelId":12,"timestamp":1786298460,"senderName":"user","text":"test"}}
```
## Method reference
@@ -600,13 +600,16 @@ Parameters:
### jamulusclient/chatTextReceived
-Emitted when a chat text is received.
+Emitted when a structured chat message (message 37) is received. Carries semantic data, never presentation markup.
Parameters:
| Name | Type | Description |
| --- | --- | --- |
-| params.chatText | string | The chat text. |
+| params.channelId | number | Channel ID of the sending client, or 255 for server/RPC-originated messages. |
+| params.timestamp | number | Unix timestamp (seconds) stamped at the server. |
+| params.senderName | string | Name of the sending client (empty for server/RPC-originated messages). |
+| params.text | string | Chat message text. |
### jamulusclient/clientListReceived
@@ -698,8 +701,10 @@ Parameters:
| Name | Type | Description |
| --- | --- | --- |
-| params.id | number | Channel ID of sending client or -1 for RPC sent messages. |
-| params.chatMessage | string | Chat message text. |
+| params.channelId | number | Channel ID of sending client or -1 for RPC sent messages. |
+| params.timestamp | number | Unix timestamp (seconds) stamped at the server. |
+| params.senderName | string | Name of the sending client (empty for RPC sent messages). |
+| params.text | string | Chat message text. |
### jamulusserver/clientConnected
diff --git a/docs/design-chat-redesign.md b/docs/design-chat-redesign.md
new file mode 100644
index 0000000000..f5a17d00dc
--- /dev/null
+++ b/docs/design-chat-redesign.md
@@ -0,0 +1,224 @@
+# Redesigned Chat Message — Design Document (v2)
+
+Status: Draft (for review) · Date: 2026-08-09 · Scope: Protocol, server, client, JSON-RPC, accessibility, compatibility, tests
+
+Incorporates the revised brief. Resolves open decisions A–E and replaces the earlier version-based capability gate with an explicit in-session handshake.
+
+## 1. Objective
+
+Normal chat messages contain **data, not presentation markup**. The server supplies facts (channel ID, timestamp, sender name, plain UTF-8 text); the client supplies presentation (timezone/locale/colours/typography/a11y/URLs/filtering/mute).
+
+```text
+Client chat input → Server → legacy client → message 18 (legacy HTML)
+ └─ structured client → message 37 (data) → ChatMessage → Client UI
+```
+
+**Invariant: normal user chat text is never interpreted as HTML by the receiver.** Compatibility with existing clients and JSON-RPC semantics preserved where practical.
+
+## 2. Problems solved
+
+1. `PROTMESSID_CHAT_TEXT` (18) carries a server-generated HTML display string (`src/server.cpp:1352-1367`).
+2. Server picks colours (palette `src/server.cpp:150-156`) and time formatting.
+3. Desktop themes cannot control server HTML (issue #3740).
+4. Accessibility constrained by the rich-text model.
+5. RPC injection can bypass escaping (`src/serverrpc.cpp:112`, `:132`).
+6. URL linkification done via HTML manipulation (`src/chatdlg.cpp:142-157`).
+7. Inconsistent length limits (1600 vs 1800 vs unclamped).
+8. Welcome coupled to raw-HTML mechanism (`src/clientdlg.cpp:887`).
+9. Client JSON-RPC exposes server HTML (`src/clientrpc.cpp:54-62`).
+10. Per-channel mute/filtering need structured source info.
+
+## 3. Design decisions (resolved)
+
+| Question | Decision |
+| --- | --- |
+| Wire format | Channel ID + uint32 epoch-seconds + sender name + UTF-8 text |
+| Compatibility | Dual-format: 37 to capable clients, legacy HTML 18 to others |
+| **A. Capability** | **Explicit in-session handshake** (new IDs 38/39), not version guessing (§4) |
+| Timestamp | Epoch UTC stamped at fan-out; client renders local, locale-aware short format |
+| **B. Sender identity** | **Name snapshotted onto the wire at fan-out** (§5.2) — history stable across renames/channel reuse |
+| Welcome | Classified **administrative rich-content message**; raw HTML retained (§9) |
+| **C. Chat UI** | **Evaluate** `QListWidget` vs `QListView`+model+delegate vs retained `QTextBrowser`; list-based preferred, `QTextBrowser` lowest (§12) |
+| URL linkification | Preserved; escape-then-linkify of bare `http(s)://`; confirm-before-open stays |
+| Old-format parsing in client | Kept for the compatibility window (new client ↔ old server) |
+| **E. Client RPC** | **Structured** `{channelId, timestamp, text, senderName}`, breaking, changelog-flagged (§15.2) |
+| Order | Slice 1 protocol+capability+server, Slice 2 client data/model, Slice 3 client UI (§18) |
+
+## 4. Capability negotiation (decision A)
+
+**Explicit in-session handshake, not version numbers** (forks, backports, dev builds, unusual version strings make thresholds unreliable). Version-gate failure mode — server sends 37 to a client that cannot parse it → message silently lost — is unacceptable because unknown IDs are ACKed unconditionally (`src/protocol.cpp:806-887`, `CreateAndImmSendAcknMess` at `:887`).
+
+Model on the split handshake (`PROTMESSID_REQ_SPLIT_MESS_SUPPORT` 34 / `PROTMESSID_SPLIT_MESS_SUPPORTED` 35, `src/protocol.h:85-86`):
+
+- Server sends `REQ_CHAT_TEXT_SUPPORT` (38, server→client) during connection bootstrap, mirroring `CreateReqSplitMessSupportMes()` at `src/server.cpp:417`.
+- Client replies `CHAT_TEXT_SUPPORTED` (39, client→server), mirroring `src/channel.cpp:500-505`.
+- IDs 38/39 are free (body 0–36; CLM 1000–1999; split 2001).
+- Store per-connection `bSupportsStructuredChat` on `CChannel`. Today nothing is retained about client capability: `CChannel::OnVersionAndOSReceived` (`src/channel.cpp:171`) only sets `bUseSequenceNumber` internally; its signal is **not** connected to any server slot (`connectChannelSignalsToServerSlots`, `src/server.cpp:328-362`). Needed: stored member + new signal + server connection.
+- **Default `false` until confirmed → legacy 18.** Safe failure = "unknown/new client → legacy", never silent loss. A chat before the capability reply is a benign race handled by the legacy default.
+- No generalized capability framework — narrowly scoped to structured chat.
+
+## 5. Protocol message 37
+
+### 5.1 ID
+
+```cpp
+#define PROTMESSID_CHAT_TEXT_CHANNEL 37 // chat text with source channel ID, timestamp and sender name
+```
+
+Next free body slot. **Server→client only:** clients keep sending chat as legacy 18 (they don't reliably know their server channel ID; the server stamps time and name). Server is the only producer.
+
+### 5.2 Wire format
+
+```text
+| 1 byte channel ID | 4 bytes uint32 timestamp | 2 bytes name len | n bytes name | 2 bytes text len | m bytes text |
+```
+
+- Little-endian per `PutValOnStream`/`GetValFromStream` (`src/protocol.cpp:2976-3006`; `uint32_t`-based, assert `iNumOfBytes <= 4` at `:2976`, `:2826` — a uint64 would need new helpers). uint32 epoch-seconds fits directly; unambiguous until 2106.
+- Name/text length fields are **UTF-8 byte counts** (2-byte) via existing stream helpers (`:2992`, `:2846`).
+- **Sender name snapshotted at fan-out** from `vecChannels[iCurChanID].GetName()` (`src/server.cpp:1356`). RPC/255 → empty name → client placeholder. Decode cap `MAX_LEN_FADER_TAG` (16), consistent with CONN_CLIENTS_LIST names (`src/protocol.cpp:1268`, `global.h:284`).
+- Timestamp: `QDateTime::currentSecsSinceEpoch()` (UTC) at fan-out.
+
+### 5.3 Text length semantics (decision D)
+
+`MAX_LEN_CHAT_TEXT == 1600` = **QString::size(), UTF-16 code units**, enforced **post-decode** by `GetStringFromStream` (`strOut.size() > iMaxStringLen`, `src/protocol.cpp:2881`); the wire length is the **UTF-8 byte count**. The client input clamp uses the same unit (`src/chatdlg.cpp:111`) — semantics are self-consistent; the new decoder must reuse the same `size()` check.
+
+- Reject on: inconsistent length, truncation (name/timestamp/text), decoded text over limit, trailing bytes (`iPos != vecData.Size()`, as `src/protocol.cpp:1430-1433`).
+- `MAX_LEN_CHAT_TEXT_PLUS_HTML` (1800) no longer needed for message 37.
+- Max body ≈ 1+4+2+≤48 (name 16×3)+2+≤4800 (text 1600×3 bytes/UTF-16 unit) ≈ **4857 bytes** — ~9 split parts, within 36-part capacity (`MAX_NUM_MESS_SPLIT_PARTS = MAX_SIZE_BYTES_NETW_BUF/550`, `src/protocol.h:126`, `global.h:169`).
+- Non-trivial message 37 always takes the split path (`src/protocol.cpp:584`, `:590`) — already true for legacy chat; all current clients negotiate split. Created via `CreateAndSendMessage`; client parses the reassembled body like legacy chat.
+
+## 6. Channel IDs and sentinels
+
+- The 1-byte field carries the **server channel ID** advertised in `PROTMESSID_CONN_CLIENTS_LIST` (`src/protocol.cpp:1213`, `CChannelInfo.iChanID`). The server channel index *is* the ID (`CreateChannelList` passes the index straight into `CChannelInfo`, `src/server.cpp:1319`) — no index→ID mapping exists or is needed.
+- **Valid IDs `0..MAX_NUM_CHANNELS-1`** (`MAX_NUM_CHANNELS = 150`). **ID 0 is a real client and is never a sentinel.**
+- **`255` (0xFF) is the server/JSON-RPC sentinel** for RPC `broadcastChatMessage`/`privateChatMessage`: outside 0..149, fits the byte. `INVALID_CLIENT_ID = -1` (`src/serverrpc.cpp:51`) doesn't fit a byte and stays internal-only.
+- Client reject rule: **"reject > 149 except 255"**, not "reject > MAX_NUM_CHANNELS" (which would wrongly admit 150..254).
+- Mapping: JSON-RPC `-1` → internal `INVALID_CLIENT_ID` → wire `255`, converted only at the fan-out boundary.
+
+## 7. Server fan-out
+
+Client chat + both RPC paths converge on **one** branchy fan-out. Today: `CreateAndSendChatTextForAllConChannels` (`src/server.cpp:1352-1367`, formats + fans out; client chat only) vs `SendChatTextToAllConChannels` (`src/server.cpp:1369-1381`, raw unescaped; called by RPC, `src/serverrpc.cpp:112`). Merge the latter into the former.
+
+1. **Clamp** to `MAX_LEN_CHAT_TEXT` up front — today's fan-out never clamps (client UI clamps; welcome at `src/server.cpp:1655`; RPC-private at `src/serverrpc.cpp:125`; RPC-broadcast and client-sourced do not).
+2. **Stamp** `QDateTime::currentSecsSinceEpoch()` (UTC).
+3. **Determine source ID before indexing `vecChannels`**: use `iCurChanID` directly for client chat; map RPC/`INVALID_CLIENT_ID` to `255`. **Guard: sentinel/-1 must short-circuit before `vecChannels[iCurChanID].GetName()` / `vstrChatColors[iCurChanID % 6]` (`src/server.cpp:1356`, `:1360`) — indexing with 255 or -1 is out of bounds.** Sentinel branch supplies an empty name.
+4. **Per client:** `bSupportsStructuredChat` → `CreateChatTextChannelMes ( channelID, timestamp, senderName, strChatText )` (plain, no wire escaping); legacy → existing escaped HTML string via `PROTMESSID_CHAT_TEXT`.
+
+## 8. Legacy message 18
+
+Compatibility window only: names/text escaped, consistent length enforcement, RPC uses the same safe formatting path, no new functionality depends on it. The structured path must **not** reuse the legacy formatted string internally.
+
+## 9. Welcome message (administrative rich-content message)
+
+`CServer::OnClientConnect` (`src/server.cpp:448-459`) keeps sending the raw-HTML welcome via `PROTMESSID_CHAT_TEXT` — administrators depend on HTML/CSS styling. Classified as an **administrative rich-content message**, not normal chat; **normal user chat never enters this path**. Prefix detection (`src/clientdlg.cpp:887`) remains temporarily; the HTML mechanism is not generalized; long-term migration out of scope.
+
+## 10. Client data model
+
+Protocol handlers must not build widgets. Semantic layer between protocol and presentation:
+
+```cpp
+struct ChatMessage
+{
+ uint8_t channelId;
+ uint32_t timestamp;
+ QString senderName; // wire snapshot (decision B)
+ QString text;
+};
+```
+
+```text
+Protocol → ChatMessage → model/presentation layer → desktop UI + accessibility + filtering/muting + JSON-RPC
+```
+
+Not `Protocol → QListWidgetItem`. With the name on the wire, no live lookup is needed for historical identity; channel lookup only serves filtering/muting.
+
+## 11. Client rendering
+
+Plain-data rendering; `hello`, `
`, ``, `` appear as ordinary text.
+
+- New `OnChatTextChannelReceived` builds `ChatMessage` and appends to the chat model.
+- `AddChatText` (`src/chatdlg.cpp:134-161`) retained for the legacy path only; remove the `href\s*=|src\s*=` heuristic (`:142`).
+- Linkify: apply the HTTP(S)-wrap regex (`:155-156`) to escaped/plain text **after** escaping, never before; confirm-before-open stays.
+
+## 12. Chat UI choice (decision C)
+
+Evaluate, don't assume:
+
+1. `QListWidget` — per-item `Qt::PlainText`, `Qt::UserRole` channel ID; per-item a11y via `QAccessibleItemView`.
+2. `QListView` + model + delegate — best fit for `ChatMessage`; links/copy/select/keyboard via delegate + editor flags; same per-item a11y; scales to high volume.
+3. Retained `QTextBrowser` with fully client-controlled escaped generation — **rank lowest**: the source of both the a11y ceiling (one text node to screen readers) and the HTML-interpretation surface.
+
+Criteria: a11y, keyboard navigation, select/copy, URL interaction, visual formatting, high-volume performance, ease, maintainability, Qt compatibility. **Requirement is structured data + safe presentation, not a specific widget.** If `QListWidget` needs a heavy custom delegate/hit-test/a11y layer to reproduce clickable URLs, prefer model/view.
+
+Evaluation outcome (Slice 3, 2026-08-09): **retained `QTextBrowser`** with fully client-controlled escaped generation. Rationale: (1) clickable-URL-with-confirm requires an anchor hit-test delegate in any list view — the "heavy layer" the design flags; (2) the legacy old-server path must keep rendering server HTML, so two rendering modes would be needed in a list view; (3) the core requirement — structured data + safe presentation — is fully met by client-side escaping (`EscapeAndLinkifyText`) on the retained widget. A11y improvement delivered via `QAccessibleAnnouncementEvent` live announcements; per-message a11y nodes (list-view benefit) remain a follow-up candidate. The URL linkify regex was tightened to terminate at `&` (HTML-entity boundary in escaped text) so URLs adjacent to escaped markup do not over-match.
+
+## 13. Accessibility
+
+Improve, not merely preserve: accessible name (`src/chatdlg.cpp:58`); per-message exposure (list-based view gives per-item nodes); live announcements — today `QAccessibleValueChangeEvent` (`:137`), prefer `QAccessibleAnnouncementEvent` (API since Qt 6.5; the project's existing guarded use at `src/connectdlg.cpp:1169` is **Qt ≥ 6.8** — match that guard); keyboard nav; selectable/copyable text; meaningful sender/timestamp/text semantics.
+
+## 14. Name/channel lookup
+
+Name is on the wire, so the channel ID is for filtering/muting, not identity. Client channel structures store no name (`CClientChannel`, `src/client.h:131`; CONN_CLIENTS_LIST names remapped in `OnConClientListMesReceived`, `src/client.cpp:353`, `:377`). For lookups: unknown IDs must not crash (`FindClientChannel` → `INVALID_INDEX` for IDs ≥ `MAX_NUM_CHANNELS`, `src/client.cpp:1740`, covering 255); `255`/unknown → "Server"/placeholder; pre-list messages (connect race / UDP loss) → placeholder, repaired on refresh.
+
+## 15. JSON-RPC
+
+### 15.1 Server
+
+`broadcastChatMessage` (`src/serverrpc.cpp:98-114`) and `privateChatMessage` (`:116-138`) route through the merged fan-out (§7) — no HTML construction; wire ID `255`; clamping added to broadcast (`private` already clamps at `:125`). Public API keeps `-1` for RPC messages (`chatMessageReceived`, `src/serverrpc.cpp:86-96`); `-1 → 255` conversion only at the fan-out boundary; 255 never surfaces in the public API.
+
+### 15.2 Client
+
+`jamulusclient/chatTextReceived` (`src/clientrpc.cpp:54-62`) stops exposing server HTML. **Structured:**
+
+```json
+{ "channelId": 12, "timestamp": 1786298460, "text": "hello" }
+```
+
+plus `senderName` (decision B). Breaking — changelog-flagged.
+
+## 16. Testing
+
+Part of the feature, not deferred. **Dependency:** repo currently has only the commented-out `CTestbench` (`src/protocol.h:161-183`) — land the test-infrastructure PR first or add a small standalone target for 37 round-trips.
+
+- **Serialization:** ASCII, UTF-8, empty, max-length, over-limit, channel 0, channel 149, channel 255, invalid 150–254, malformed length, truncated packet/timestamp, trailing data.
+- **Compatibility:** all four old/new server × old/new client combos. Expected: new+old→18; new+new capable→37; old+new→18 (new client keeps the legacy parser).
+- **Capability:** absent, supported, malformed/unknown, message before negotiation completes, reconnect, state reset on disconnect. Safe default always legacy.
+- **Security:** `hello`, `
`, ``, ``, HTML+URL combinations. Invariant: user text can never become executable/unintended HTML.
+
+Status (2026-08-09): the standalone `tests/chatprotocol` target covers serialization, reject rules, split reassembly, capability, the `ChatMessage` data model and the security cases above (via `EscapeAndLinkifyText`/`LinkifyURLs`); 48 assertions passing.
+- **Identity (name on wire):** rename after send → old row keeps original name; disconnect + ID reuse → old row keeps original sender.
+
+## 17. Documentation
+
+- `docs/JAMULUS_PROTOCOL.md`: message 37, field order, byte order, timestamp semantics, channel-ID semantics, 255 sentinel, server→client-only, capability negotiation (38/39), relation to legacy 18. State: *message 37 carries semantic chat data, not HTML/presentation markup.* Clarify the legacy note (`:145-147`).
+- `docs/JSON-RPC.md`: regenerate via `tools/generate_json_rpc_docs.py`; document HTML removal from client notifications, structured schema, `-1` semantics, breaking implications.
+
+## 18. Implementation plan
+
+**Slice 1 — protocol + capability + server.** No client UI change; existing clients keep parsing legacy HTML.
+
+1. `protocol.h`: add `PROTMESSID_CHAT_TEXT_CHANNEL 37`, `REQ_CHAT_TEXT_SUPPORT 38`, `CHAT_TEXT_SUPPORTED 39`; declare create/evaluate.
+2. `protocol.cpp`: 37 create/evaluate (decode caps: name `MAX_LEN_FADER_TAG`, text `MAX_LEN_CHAT_TEXT` post-decode, trailing guard); dispatch cases in `EvaluateMessageBody` (`src/protocol.cpp:806`); 38/39 handshake.
+3. `CChannel`: store `bSupportsStructuredChat`; new signal + connection (`connectChannelSignalsToServerSlots`, `src/server.cpp:328-362`); server sends REQ in the connect bootstrap (near `src/server.cpp:417`).
+4. Merge fan-out (§7): clamp, stamp, sentinel guard, name snapshot, per-client 37/18 branch; route RPC through it (`src/serverrpc.cpp:112`), keep `-1`.
+5. Tests (§16) + protocol/RPC docs (§17).
+
+**Slice 2 — client data/model.** `ChatMessage`; `OnChatTextChannelReceived`; identity semantics; safe text representation; structured RPC notification (§15.2); tests.
+
+Status (2026-08-09): **implemented.** `ChatMessage` struct in `src/chatmessage.h` (plain data: channel ID, epoch timestamp, sender-name wire snapshot, text — never HTML). `CChannel` relays the new protocol signal; `CClient::OnChatTextChannelReceived` builds the `ChatMessage` and emits `CClient::ChatTextChannelReceived`. `jamulusclient/chatTextReceived` now emits structured `{channelId, timestamp, senderName, text}` and no longer exposes server HTML. Note: the structured notification fires only for message 37; a new client against a legacy server (message 18) does not re-expose the legacy HTML string through this notification. Tests extended in `tests/chatprotocol` for the `ChatMessage` data model; full client build verified. Slice 3 (UI) still owns placeholder/identity rendering of unknown/255 channel IDs and the model/view.
+
+**Slice 3 — client UI.** Chat model/view (§12); timestamp formatting; sender rendering; URL interaction; a11y + live announcements (§13); copy/select; legacy fallback. Only the old-server path retains HTML rendering.
+
+Status (2026-08-09): **implemented.** `CChatDlg::AddChatMessage` renders structured messages entirely client-side: local/locale-aware timestamp, stable per-channel colour, escaped sender name and text via the new testable `EscapeAndLinkifyText`/`LinkifyURLs` helpers (`src/util.h`), preserving bare-http(s)-URL linkification after escaping and the confirm-before-open dialog. Legacy `AddChatText` keeps the server-HTML path (linkify only, `href\s*=|src\s*=` heuristic removed per §11). A11y live announcements moved from `QAccessibleValueChangeEvent` to the Qt ≥ 6.8-guarded `QAccessibleAnnouncementEvent` (§13). `CClientDlg::OnChatTextChannelReceived` wires the structured signal into the chat dialog (audio alert, `ShowChatWindow(false)`, welcome detection remains on the legacy path).
+
+## 19. Non-goals
+
+Per-channel mute, rich user formatting, Markdown, arbitrary HTML chat, editing, reactions, threading, persistence, generalized capability framework. Mute/filtering becomes easier (channel identity on messages) but remains follow-up.
+
+## 20. Open items
+
+- Exact names of capability messages 38/39 (per existing conventions).
+- Exact JSON-RPC schema field names — settle before Slice 2.
+- Test-infrastructure PR vs standalone target sequencing.
+
+Resolved during Slice 2: JSON-RPC field names are `channelId`, `timestamp`, `senderName`, `text` for both `jamulusclient/chatTextReceived` and `jamulusserver/chatMessageReceived`; 38 = `REQ_CHAT_TEXT_SUPPORT`, 39 = `CHAT_TEXT_SUPPORTED`.
diff --git a/src/channel.cpp b/src/channel.cpp
index 7755b7ec92..d11c03c747 100644
--- a/src/channel.cpp
+++ b/src/channel.cpp
@@ -53,6 +53,7 @@ CChannel::CChannel ( const bool bNIsServer ) :
iCurSockBufNumFrames ( INVALID_INDEX ),
bDoAutoSockBufSize ( true ),
bUseSequenceNumber ( false ), // this is important since in the client we reset on Channel.SetEnable ( false )
+ bSupportsStructuredChat ( false ),
iSendSequenceNumber ( 0 ),
iFadeInCnt ( 0 ),
iFadeInCntMax ( FADE_IN_NUM_FRAMES_DBLE_FRAMESIZE ),
@@ -113,6 +114,8 @@ CChannel::CChannel ( const bool bNIsServer ) :
QObject::connect ( &Protocol, &CProtocol::ChatTextReceived, this, &CChannel::ChatTextReceived );
+ QObject::connect ( &Protocol, &CProtocol::ChatTextChannelReceived, this, &CChannel::ChatTextChannelReceived );
+
QObject::connect ( &Protocol, &CProtocol::NetTranspPropsReceived, this, &CChannel::OnNetTranspPropsReceived );
QObject::connect ( &Protocol, &CProtocol::ReqNetTranspProps, this, &CChannel::OnReqNetTranspProps );
@@ -121,6 +124,10 @@ CChannel::CChannel ( const bool bNIsServer ) :
QObject::connect ( &Protocol, &CProtocol::SplitMessSupported, this, &CChannel::OnSplitMessSupported );
+ QObject::connect ( &Protocol, &CProtocol::ReqChatTextSupport, this, &CChannel::OnReqChatTextSupport );
+
+ QObject::connect ( &Protocol, &CProtocol::ChatTextSupported, this, &CChannel::OnChatTextSupported );
+
QObject::connect ( &Protocol, &CProtocol::LicenceRequired, this, &CChannel::LicenceRequired );
QObject::connect ( &Protocol, &CProtocol::VersionAndOSReceived, this, &CChannel::OnVersionAndOSReceived );
diff --git a/src/channel.h b/src/channel.h
index 946eae007a..0decb52699 100644
--- a/src/channel.h
+++ b/src/channel.h
@@ -111,8 +111,9 @@ class CChannel : public QObject
void ResetInfo()
{
- bIsIdentified = false;
- ChannelInfo = CChannelCoreInfo();
+ bIsIdentified = false;
+ bSupportsStructuredChat = false;
+ ChannelInfo = CChannelCoreInfo();
} // reset does not emit a message
QString GetName();
void SetChanInfo ( const CChannelCoreInfo& NChanInf );
@@ -178,6 +179,10 @@ class CChannel : public QObject
void CreateReqJitBufMes() { Protocol.CreateReqJitBufMes(); }
void CreateReqConnClientsList() { Protocol.CreateReqConnClientsList(); }
void CreateChatTextMes ( const QString& strChatText ) { Protocol.CreateChatTextMes ( strChatText ); }
+ void CreateChatTextChannelMes ( const uint8_t iChannelID, const uint32_t iTimestamp, const QString strSenderName, const QString strChatText ) { Protocol.CreateChatTextChannelMes ( iChannelID, iTimestamp, strSenderName, strChatText ); }
+ void CreateReqChatTextSupportMes() { Protocol.CreateReqChatTextSupportMes(); }
+ void CreateChatTextSupportedMes() { Protocol.CreateChatTextSupportedMes(); }
+ bool SupportsStructuredChat() const { return bSupportsStructuredChat; }
void CreateLicReqMes ( const ELicenceType eLicenceType ) { Protocol.CreateLicenceRequiredMes ( eLicenceType ); }
//### TODO: BEGIN ###//
@@ -224,6 +229,7 @@ class CChannel : public QObject
int iCurSockBufNumFrames;
bool bDoAutoSockBufSize;
bool bUseSequenceNumber;
+ bool bSupportsStructuredChat;
uint8_t iSendSequenceNumber;
// network output conversion buffer
@@ -265,6 +271,8 @@ public slots:
void OnReqNetTranspProps();
void OnReqSplitMessSupport();
void OnSplitMessSupported() { Protocol.SetSplitMessageSupported ( true ); }
+ void OnReqChatTextSupport() { Protocol.CreateChatTextSupportedMes(); }
+ void OnChatTextSupported() { bSupportsStructuredChat = true; }
void OnVersionAndOSReceived ( COSUtil::EOpSystemType eOSType, QString strVersion );
@@ -301,6 +309,7 @@ public slots:
void MuteStateHasChangedReceived ( int iChanID, bool bIsMuted );
void ReqChanInfo();
void ChatTextReceived ( QString strChatText );
+ void ChatTextChannelReceived ( uint8_t iChannelID, uint32_t iTimestamp, QString strSenderName, QString strChatText );
void ReqNetTranspProps();
void LicenceRequired ( ELicenceType eLicenceType );
void VersionAndOSReceived ( COSUtil::EOpSystemType eOSType, QString strVersion );
diff --git a/src/chatdlg.cpp b/src/chatdlg.cpp
index d49a716fb9..7f68328709 100644
--- a/src/chatdlg.cpp
+++ b/src/chatdlg.cpp
@@ -46,7 +46,20 @@
#include "chatdlg.h"
+#include
+#include
+#if QT_VERSION >= QT_VERSION_CHECK( 6, 8, 0 )
+# include
+#endif
+
/* Implementation *************************************************************/
+namespace
+{
+// client-controlled sender colours, stable per channel ID (presentation is a
+// client concern; the server only sends data)
+const char* const astrChatColors[6] = { "mediumblue", "red", "darkorchid", "green", "maroon", "coral" };
+}
+
CChatDlg::CChatDlg ( QWidget* parent ) : CBaseDlg ( parent, Qt::Window ) // use Qt::Window to get min/max window buttons
{
setupUi ( this );
@@ -133,33 +146,68 @@ void CChatDlg::OnClearChatHistory()
void CChatDlg::AddChatText ( QString strChatText )
{
- // notify accessibility plugin that text has changed
- QAccessible::updateAccessibility ( new QAccessibleValueChangeEvent ( txvChatWindow, strChatText ) );
+ // legacy (message 18) path: the server sent already-escaped HTML; we only
+ // linkify bare http(s):// URLs, the text itself is never re-interpreted
+ LinkifyURLs ( strChatText );
- // analyze strChatText to check if hyperlink (limit ourselves to http(s)://) but do not
- // replace the hyperlinks if any HTML code for a hyperlink was found (the user has done the HTML
- // coding hisself and we should not mess with that)
- if ( !strChatText.contains ( QRegularExpression ( "href\\s*=|src\\s*=" ) ) )
- {
- // searches for all occurrences of http(s) and cuts until a space (\S matches any non-white-space
- // character and the + means that matches the previous element one or more times.)
- // This regex now contains three parts:
- // - https?://\\S+ matches as much non-whitespace as possible after the http:// or https://,
- // subject to the next two parts, which exclude terminating punctuation
- // - (??\\[\\]{}]) is a negative look-behind assertion that disallows the match
- // from ending with one of the characters !"'()+,.:;<=>?[]{}
- // - (??\\[\\]{}]) is a negative look-behind assertion that disallows the match
- // from ending with a ? followed by one of the characters !"'()+,.:;<=>?[]{}
- // These last two parts must be separate, as a look-behind assertion must be fixed length.
-#define PUNCT_NOEND_URL "[!\"'()+,.:;<=>?\\[\\]{}]"
- strChatText.replace ( QRegularExpression ( "(https?://\\S+(?\\1" );
- }
+ AnnounceNewChatMessage ( strChatText );
// add new text in chat window
txvChatWindow->append ( strChatText );
}
+void CChatDlg::AddChatMessage ( const ChatMessage& message )
+{
+ // announce the plain content (sender and text) to screen readers
+ QString strAnnouncement;
+ if ( !message.senderName.isEmpty() )
+ {
+ strAnnouncement = message.senderName + ": " + message.text;
+ }
+ else
+ {
+ strAnnouncement = message.text;
+ }
+ AnnounceNewChatMessage ( strAnnouncement );
+
+ // add new structured message in chat window
+ txvChatWindow->append ( FormatChatMessage ( message ) );
+}
+
+void CChatDlg::AnnounceNewChatMessage ( const QString& strAnnouncement )
+{
+#if QT_VERSION >= QT_VERSION_CHECK( 6, 8, 0 )
+ // prefer a proper live region announcement over the value-change event
+ QAccessible::updateAccessibility ( new QAccessibleAnnouncementEvent ( txvChatWindow, strAnnouncement ) );
+#else
+ QAccessible::updateAccessibility ( new QAccessibleValueChangeEvent ( txvChatWindow, strAnnouncement ) );
+#endif
+}
+
+QString CChatDlg::FormatChatMessage ( const ChatMessage& message ) const
+{
+ // the client supplies all presentation: local, locale-aware time, a stable
+ // per-channel sender colour and escaped plain text; user data is escaped so
+ // that it is never interpreted as HTML
+ const QString strTime = QLocale().toString ( QDateTime::fromSecsSinceEpoch ( message.timestamp ).toLocalTime().time(),
+ QLocale::ShortFormat );
+
+ QString strSenderName = message.senderName;
+ if ( strSenderName.isEmpty() )
+ {
+ // server/RPC-originated messages carry the wire sentinel channel ID and
+ // no sender name; unknown channels get a neutral placeholder
+ strSenderName = ( message.channelId == SERVER_CHAT_CHANNEL_ID ) ? tr ( "Server" ) : tr ( "Unknown" );
+ }
+
+ const QString sCurColor = astrChatColors[message.channelId % 6];
+
+ const QString strHeader =
+ "(" + strTime + ") " + strSenderName.toHtmlEscaped() + " ";
+
+ return strHeader + EscapeAndLinkifyText ( message.text );
+}
+
void CChatDlg::OnAnchorClicked ( const QUrl& Url )
{
// only allow http(s) URLs to be opened in an external browser
diff --git a/src/chatdlg.h b/src/chatdlg.h
index eaad2c1cbe..db8c41456c 100644
--- a/src/chatdlg.h
+++ b/src/chatdlg.h
@@ -59,6 +59,7 @@
#include
#include "global.h"
#include "util.h"
+#include "chatmessage.h"
#include "ui_chatdlgbase.h"
/* Classes ********************************************************************/
@@ -70,6 +71,7 @@ class CChatDlg : public CBaseDlg, private Ui_CChatDlgBase
CChatDlg ( QWidget* parent = nullptr );
void AddChatText ( QString strChatText );
+ void AddChatMessage ( const ChatMessage& message );
public slots:
void OnSendText();
@@ -82,4 +84,8 @@ public slots:
signals:
void NewLocalInputText ( QString strNewText );
+
+private:
+ void AnnounceNewChatMessage ( const QString& strAnnouncement );
+ QString FormatChatMessage ( const ChatMessage& message ) const;
};
diff --git a/src/chatmessage.h b/src/chatmessage.h
new file mode 100644
index 0000000000..9b2d3ede9b
--- /dev/null
+++ b/src/chatmessage.h
@@ -0,0 +1,43 @@
+/******************************************************************************\
+ * Copyright (c) 2026
+ *
+ * This file is part of Jamulus.
+ *
+ * Author(s):
+ * Jamulus contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ *
+ \******************************************************************************/
+
+#pragma once
+
+#include
+#include
+
+#include
+
+// Semantic chat message data as carried by protocol message 37. Contains
+// facts, not presentation markup: the sender name is the wire snapshot taken
+// at server fan-out, and the channel ID serves filtering/muting rather than
+// identity. SERVER_CHAT_CHANNEL_ID (255) marks server/RPC-originated messages.
+struct ChatMessage
+{
+ uint8_t channelId; // server channel ID, or SERVER_CHAT_CHANNEL_ID (255) for server/RPC messages
+ uint32_t timestamp; // epoch seconds (UTC), stamped at the server
+ QString senderName; // sender name snapshotted on the wire (empty for server/RPC messages)
+ QString text; // plain UTF-8 chat text, never HTML
+};
+
+Q_DECLARE_METATYPE ( ChatMessage )
diff --git a/src/client.cpp b/src/client.cpp
index 6ee048a863..18ec89af0d 100644
--- a/src/client.cpp
+++ b/src/client.cpp
@@ -48,6 +48,8 @@
#include "settings.h"
#include "util.h"
+#include
+
/* Implementation *************************************************************/
CClient::CClient ( const quint16 iPortNumber,
const quint16 iQosNumber,
@@ -151,6 +153,8 @@ CClient::CClient ( const quint16 iPortNumber,
QObject::connect ( &Channel, &CChannel::ChatTextReceived, this, &CClient::ChatTextReceived );
+ QObject::connect ( &Channel, &CChannel::ChatTextChannelReceived, this, &CClient::OnChatTextChannelReceived );
+
QObject::connect ( &Channel, &CChannel::ClientIDReceived, this, &CClient::OnClientIDReceived );
QObject::connect ( &Channel, &CChannel::RawAudioSupported, this, &CClient::OnRawAudioSupported );
@@ -420,6 +424,13 @@ void CClient::OnConClientListMesReceived ( CVector vecChanInfo )
emit ConClientListMesReceived ( vecChanInfo );
}
+void CClient::OnChatTextChannelReceived ( uint8_t iChannelID, uint32_t iTimestamp, QString strSenderName, QString strChatText )
+{
+ // build the semantic chat message and pass it on to the model/presentation
+ // layer; senderName is the wire snapshot, no live lookup is needed
+ emit ChatTextChannelReceived ( ChatMessage{ iChannelID, iTimestamp, std::move ( strSenderName ), std::move ( strChatText ) } );
+}
+
void CClient::CreateServerJitterBufferMessage()
{
// per definition in the client: if auto jitter buffer is enabled, both,
diff --git a/src/client.h b/src/client.h
index b56e5c42d1..37f9610a4f 100644
--- a/src/client.h
+++ b/src/client.h
@@ -61,6 +61,7 @@
#include "socket.h"
#include "channel.h"
#include "util.h"
+#include "chatmessage.h"
#include "plugins/audioreverb.h"
#include "buffer.h"
#include "signalhandler.h"
@@ -485,10 +486,12 @@ protected slots:
void OnMuteStateHasChangedReceived ( int iServerChanID, bool bIsMuted );
void OnCLChannelLevelListReceived ( CHostAddress InetAddr, CVector vecLevelList );
void OnConClientListMesReceived ( CVector vecChanInfo );
+ void OnChatTextChannelReceived ( uint8_t iChannelID, uint32_t iTimestamp, QString strSenderName, QString strChatText );
signals:
void ConClientListMesReceived ( CVector vecChanInfo );
void ChatTextReceived ( QString strChatText );
+ void ChatTextChannelReceived ( ChatMessage chatMessage );
void ClientIDReceived ( int iChanID );
void MuteStateHasChangedReceived ( int iChanID, bool bIsMuted );
void LicenceRequired ( ELicenceType eLicenceType );
diff --git a/src/clientdlg.cpp b/src/clientdlg.cpp
index d939b54aa2..8cbeac3b2b 100644
--- a/src/clientdlg.cpp
+++ b/src/clientdlg.cpp
@@ -511,6 +511,8 @@ CClientDlg::CClientDlg ( CClient* pNCliP,
QObject::connect ( pClient, &CClient::ChatTextReceived, this, &CClientDlg::OnChatTextReceived );
+ QObject::connect ( pClient, &CClient::ChatTextChannelReceived, this, &CClientDlg::OnChatTextChannelReceived );
+
QObject::connect ( pClient, &CClient::ClientIDReceived, this, &CClientDlg::OnClientIDReceived );
QObject::connect ( pClient, &CClient::MuteStateHasChangedReceived, this, &CClientDlg::OnMuteStateHasChangedReceived );
@@ -890,6 +892,21 @@ void CClientDlg::OnChatTextReceived ( QString strChatText )
UpdateDisplay();
}
+void CClientDlg::OnChatTextChannelReceived ( ChatMessage chatMessage )
+{
+ if ( pSettings->bEnableAudioAlerts )
+ {
+ PlayAudioAlert ( QUrl::fromLocalFile ( ":sounds/res/sounds/new_message.wav" ) );
+ }
+ ChatDlg.AddChatMessage ( chatMessage );
+
+ // structured messages never carry the server welcome message (that stays
+ // on the legacy path), so never force the dialog to be upfront
+ ShowChatWindow ( false );
+
+ UpdateDisplay();
+}
+
void CClientDlg::OnLicenceRequired ( ELicenceType eLicenceType )
{
// right now only the creative common licence is supported
diff --git a/src/clientdlg.h b/src/clientdlg.h
index 6a34cc66f0..1e1131c0a1 100644
--- a/src/clientdlg.h
+++ b/src/clientdlg.h
@@ -212,6 +212,7 @@ public slots:
void OnConClientListMesReceived ( CVector vecChanInfo );
void OnChatTextReceived ( QString strChatText );
+ void OnChatTextChannelReceived ( ChatMessage chatMessage );
void OnLicenceRequired ( ELicenceType eLicenceType );
void OnSoundDeviceChanged ( QString strError );
diff --git a/src/clientrpc.cpp b/src/clientrpc.cpp
index 4cef578b17..c0a1e08702 100644
--- a/src/clientrpc.cpp
+++ b/src/clientrpc.cpp
@@ -52,12 +52,18 @@ CClientRpc::CClientRpc ( CClient* pClient, CClientSettings* pSettings, CRpcServe
m_pSettings ( pSettings )
{
/// @rpc_notification jamulusclient/chatTextReceived
- /// @brief Emitted when a chat text is received.
- /// @param {string} params.chatText - The chat text.
- connect ( pClient, &CClient::ChatTextReceived, [=] ( QString strChatText ) {
+ /// @brief Emitted when a structured chat message (message 37) is received. Carries semantic data, never presentation markup.
+ /// @param {number} params.channelId - Channel ID of the sending client, or 255 for server/RPC-originated messages.
+ /// @param {number} params.timestamp - Unix timestamp (seconds) stamped at the server.
+ /// @param {string} params.senderName - Name of the sending client (empty for server/RPC-originated messages).
+ /// @param {string} params.text - Chat message text.
+ connect ( pClient, &CClient::ChatTextChannelReceived, [=] ( ChatMessage chatMessage ) {
pRpcServer->BroadcastNotification ( "jamulusclient/chatTextReceived",
QJsonObject{
- { "chatText", strChatText },
+ { "channelId", static_cast ( chatMessage.channelId ) },
+ { "timestamp", static_cast ( chatMessage.timestamp ) },
+ { "senderName", chatMessage.senderName },
+ { "text", chatMessage.text },
} );
} );
diff --git a/src/global.h b/src/global.h
index 5414a58c41..a9d13e3824 100644
--- a/src/global.h
+++ b/src/global.h
@@ -232,6 +232,13 @@ LED bar: lbr
// maximum number of connected clients at the server (must not be larger than 256)
#define MAX_NUM_CHANNELS 150 // max number channels for server
+// channel ID sentinel for server/RPC-originated chat messages on the wire (message 37)
+#define SERVER_CHAT_CHANNEL_ID 255
+
+// internal marker for RPC-originated chat messages (no sender channel); maps to
+// SERVER_CHAT_CHANNEL_ID at the fan-out boundary and never surfaces in the public API
+#define INVALID_CLIENT_ID -1
+
// actual number of used channels in the server
// this parameter can safely be changed from 1 to MAX_NUM_CHANNELS
// without any other changes in the code
diff --git a/src/protocol.cpp b/src/protocol.cpp
index 3030c2dca1..8d4ed28d20 100644
--- a/src/protocol.cpp
+++ b/src/protocol.cpp
@@ -849,6 +849,10 @@ void CProtocol::ParseMessageBody ( const CVector& vecbyMesBodyData, con
EvaluateChatTextMes ( vecbyMesBodyDataRef );
break;
+ case PROTMESSID_CHAT_TEXT_CHANNEL:
+ EvaluateChatTextChannelMes ( vecbyMesBodyDataRef );
+ break;
+
case PROTMESSID_NETW_TRANSPORT_PROPS:
EvaluateNetwTranspPropsMes ( vecbyMesBodyDataRef );
break;
@@ -865,6 +869,14 @@ void CProtocol::ParseMessageBody ( const CVector& vecbyMesBodyData, con
EvaluateSplitMessSupportedMes();
break;
+ case PROTMESSID_REQ_CHAT_TEXT_SUPPORT:
+ EvaluateReqChatTextSupportMes();
+ break;
+
+ case PROTMESSID_CHAT_TEXT_SUPPORTED:
+ EvaluateChatTextSupportedMes();
+ break;
+
case PROTMESSID_RAWAUDIO_SUPPORTED:
EvaluateRawAudioSupportedMes();
break;
@@ -1438,6 +1450,80 @@ bool CProtocol::EvaluateChatTextMes ( const CVector& vecData )
return false; // no error
}
+void CProtocol::CreateChatTextChannelMes ( const uint8_t iChannelID, const uint32_t iTimestamp, const QString strSenderName, const QString strChatText )
+{
+ int iPos = 0; // init position pointer
+
+ // convert strings to utf-8
+ const QByteArray strUTF8SenderName = strSenderName.toUtf8();
+ const QByteArray strUTF8ChatText = strChatText.toUtf8();
+
+ // size of message body
+ const int iEntrLen = 1 + // channel ID
+ 4 + // timestamp
+ 2 + strUTF8SenderName.size() + // sender name
+ 2 + strUTF8ChatText.size(); // chat text
+
+ // build data vector
+ CVector vecData ( iEntrLen );
+
+ // channel ID
+ PutValOnStream ( vecData, iPos, iChannelID, 1 );
+
+ // timestamp (epoch seconds, UTC)
+ PutValOnStream ( vecData, iPos, iTimestamp, 4 );
+
+ // sender name
+ PutStringUTF8OnStream ( vecData, iPos, strUTF8SenderName );
+
+ // chat text
+ PutStringUTF8OnStream ( vecData, iPos, strUTF8ChatText );
+
+ CreateAndSendMessage ( PROTMESSID_CHAT_TEXT_CHANNEL, vecData );
+}
+
+bool CProtocol::EvaluateChatTextChannelMes ( const CVector& vecData )
+{
+ int iPos = 0; // init position pointer
+
+ // check size: at minimum the channel ID, timestamp and both string length fields
+ if ( vecData.Size() < 1 + 4 + 2 + 2 )
+ {
+ return true; // return error code
+ }
+
+ // channel ID
+ const uint8_t iChannelID = static_cast ( GetValFromStream ( vecData, iPos, 1 ) );
+
+ // timestamp
+ const uint32_t iTimestamp = static_cast ( GetValFromStream ( vecData, iPos, 4 ) );
+
+ // sender name
+ QString strSenderName;
+ if ( GetStringFromStream ( vecData, iPos, MAX_LEN_FADER_TAG, strSenderName ) )
+ {
+ return true; // return error code
+ }
+
+ // chat text
+ QString strChatText;
+ if ( GetStringFromStream ( vecData, iPos, MAX_LEN_CHAT_TEXT, strChatText ) )
+ {
+ return true; // return error code
+ }
+
+ // check size: all data is read, the position must now be at the end
+ if ( iPos != vecData.Size() )
+ {
+ return true; // return error code
+ }
+
+ // invoke message action
+ emit ChatTextChannelReceived ( iChannelID, iTimestamp, strSenderName, strChatText );
+
+ return false; // no error
+}
+
void CProtocol::CreateNetwTranspPropsMes ( const CNetworkTransportProps& NetTrProps )
{
int iPos = 0; // init position pointer
@@ -1584,6 +1670,26 @@ bool CProtocol::EvaluateSplitMessSupportedMes()
return false; // no error
}
+void CProtocol::CreateReqChatTextSupportMes() { CreateAndSendMessage ( PROTMESSID_REQ_CHAT_TEXT_SUPPORT, CVector ( 0 ) ); }
+
+bool CProtocol::EvaluateReqChatTextSupportMes()
+{
+ // invoke message action
+ emit ReqChatTextSupport();
+
+ return false; // no error
+}
+
+void CProtocol::CreateChatTextSupportedMes() { CreateAndSendMessage ( PROTMESSID_CHAT_TEXT_SUPPORTED, CVector ( 0 ) ); }
+
+bool CProtocol::EvaluateChatTextSupportedMes()
+{
+ // invoke message action
+ emit ChatTextSupported();
+
+ return false; // no error
+}
+
void CProtocol::CreateRawAudioSupportedMes() { CreateAndSendMessage ( PROTMESSID_RAWAUDIO_SUPPORTED, CVector ( 0 ) ); }
bool CProtocol::EvaluateRawAudioSupportedMes()
diff --git a/src/protocol.h b/src/protocol.h
index 8d4125a9ab..b47d6c5c74 100644
--- a/src/protocol.h
+++ b/src/protocol.h
@@ -85,6 +85,9 @@
#define PROTMESSID_REQ_SPLIT_MESS_SUPPORT 34 // request support for split messages
#define PROTMESSID_SPLIT_MESS_SUPPORTED 35 // split messages are supported
#define PROTMESSID_RAWAUDIO_SUPPORTED 36 // raw (uncompressed) audio is supported
+#define PROTMESSID_CHAT_TEXT_CHANNEL 37 // chat text with source channel ID, timestamp and sender name
+#define PROTMESSID_REQ_CHAT_TEXT_SUPPORT 38 // request support for structured chat text
+#define PROTMESSID_CHAT_TEXT_SUPPORTED 39 // structured chat text is supported
// message IDs of connection less messages (CLM)
// DEFINITION -> start at 1000, end at 1999, see IsConnectionLessMessageID
@@ -147,10 +150,13 @@ class CProtocol : public QObject
void CreateChanInfoMes ( const CChannelCoreInfo ChanInfo );
void CreateReqChanInfoMes();
void CreateChatTextMes ( const QString strChatText );
+ void CreateChatTextChannelMes ( const uint8_t iChannelID, const uint32_t iTimestamp, const QString strSenderName, const QString strChatText );
void CreateNetwTranspPropsMes ( const CNetworkTransportProps& NetTrProps );
void CreateReqNetwTranspPropsMes();
void CreateReqSplitMessSupportMes();
void CreateSplitMessSupportedMes();
+ void CreateReqChatTextSupportMes();
+ void CreateChatTextSupportedMes();
void CreateRawAudioSupportedMes();
void CreateLicenceRequiredMes ( const ELicenceType eLicenceType );
void CreateOpusSupportedMes();
@@ -284,10 +290,13 @@ class CProtocol : public QObject
bool EvaluateChanInfoMes ( const CVector& vecData );
bool EvaluateReqChanInfoMes();
bool EvaluateChatTextMes ( const CVector& vecData );
+ bool EvaluateChatTextChannelMes ( const CVector& vecData );
bool EvaluateNetwTranspPropsMes ( const CVector& vecData );
bool EvaluateReqNetwTranspPropsMes();
bool EvaluateReqSplitMessSupportMes();
bool EvaluateSplitMessSupportedMes();
+ bool EvaluateReqChatTextSupportMes();
+ bool EvaluateChatTextSupportedMes();
bool EvaluateRawAudioSupportedMes();
bool EvaluateLicenceRequiredMes ( const CVector& vecData );
bool EvaluateVersionAndOSMes ( const CVector& vecData );
@@ -350,10 +359,13 @@ public slots:
void ChangeChanInfo ( CChannelCoreInfo ChanInfo );
void ReqChanInfo();
void ChatTextReceived ( QString strChatText );
+ void ChatTextChannelReceived ( uint8_t iChannelID, uint32_t iTimestamp, QString strSenderName, QString strChatText );
void NetTranspPropsReceived ( CNetworkTransportProps NetworkTransportProps );
void ReqNetTranspProps();
void ReqSplitMessSupport();
void SplitMessSupported();
+ void ReqChatTextSupport();
+ void ChatTextSupported();
void RawAudioSupported();
void LicenceRequired ( ELicenceType eLicenceType );
void VersionAndOSReceived ( COSUtil::EOpSystemType eOSType, QString strVersion );
diff --git a/src/server.cpp b/src/server.cpp
index a49eab776c..f214b47222 100644
--- a/src/server.cpp
+++ b/src/server.cpp
@@ -416,6 +416,9 @@ void CServer::OnNewConnection ( int iChID, int iTotChans, CHostAddress RecHostAd
// query support for split messages in the client
vecChannels[iChID].CreateReqSplitMessSupportMes();
+ // query support for structured chat text in the client
+ vecChannels[iChID].CreateReqChatTextSupportMes();
+
// on a new connection we query the network transport properties for the
// audio packets (to use the correct network block size and audio
// compression properties, etc.)
@@ -1349,35 +1352,58 @@ void CServer::CreateAndSendChanListForThisChan ( const int iCurChanID )
vecChannels[iCurChanID].CreateConClientListMes ( vecChanInfo );
}
-void CServer::CreateAndSendChatTextForAllConChannels ( const int iCurChanID, const QString& strChatText )
+void CServer::CreateAndSendChatTextForAllConChannels ( const int iSendingChanID, const QString& strChatText )
{
- // Create message which is sent to all connected clients -------------------
- // get client name
- QString ChanName = vecChannels[iCurChanID].GetName();
-
- // add time and name of the client at the beginning of the message text and
- // use different colors
- QString sCurColor = vstrChatColors[iCurChanID % vstrChatColors.Size()];
-
- const QString strActualMessageText = "(" + QTime::currentTime().toString ( "hh:mm:ss AP" ) + ") " +
- ChanName.toHtmlEscaped() + " " + strChatText.toHtmlEscaped();
-
- // Send chat text to all connected clients ---------------------------------
- SendChatTextToAllConChannels ( iCurChanID, strActualMessageText );
-}
+ // clamp to the maximum chat text length up front
+ const QString strClampedChatText = strChatText.left ( MAX_LEN_CHAT_TEXT );
+
+ // timestamp stamped at fan-out (epoch seconds, UTC)
+ const uint32_t iTimestamp = static_cast ( QDateTime::currentSecsSinceEpoch() );
+
+ // determine source channel ID and sender name before indexing vecChannels;
+ // RPC messages (no sender channel) map to the wire sentinel ID 255 and get
+ // an empty sender name
+ int iSourceChanID = iSendingChanID;
+ QString strSenderName;
+ if ( iSendingChanID == INVALID_CLIENT_ID )
+ {
+ iSourceChanID = SERVER_CHAT_CHANNEL_ID;
+ }
+ else
+ {
+ strSenderName = vecChannels[iSendingChanID].GetName();
+ }
-void CServer::SendChatTextToAllConChannels ( const int iSendingChanID, const QString& strChatText )
-{
// Send chat text to all connected clients ---------------------------------
for ( int i = 0; i < iMaxNumChannels; i++ )
{
if ( vecChannels[i].IsConnected() )
{
- vecChannels[i].CreateChatTextMes ( strChatText );
+ SendChatTextToConChannelWithSource ( i, iSourceChanID, iTimestamp, strSenderName, strClampedChatText );
}
}
// forward the message to the RPC server
- emit sentChatMessage ( iSendingChanID, strChatText );
+ emit sentChatMessage ( iSendingChanID, iTimestamp, strSenderName, strClampedChatText );
+}
+
+void CServer::SendChatTextToConChannelWithSource ( const int iCurChanID, const int iSourceChanID, const uint32_t iTimestamp, const QString& strSenderName, const QString& strChatText )
+{
+ if ( vecChannels[iCurChanID].SupportsStructuredChat() )
+ {
+ // structured chat text (message 37): plain data, no presentation markup
+ vecChannels[iCurChanID].CreateChatTextChannelMes ( iSourceChanID, iTimestamp, strSenderName, strChatText );
+ }
+ else
+ {
+ // legacy chat text (message 18): server-generated HTML with escaped
+ // name and text and a per-source color
+ const QString sCurColor = vstrChatColors[iSourceChanID % vstrChatColors.Size()];
+
+ const QString strActualMessageText = "(" + QTime::currentTime().toString ( "hh:mm:ss AP" ) + ") " +
+ strSenderName.toHtmlEscaped() + " " + strChatText.toHtmlEscaped();
+
+ vecChannels[iCurChanID].CreateChatTextMes ( strActualMessageText );
+ }
}
bool CServer::SendChatTextToConChannel ( const int iCurChanID, const QString& strChatText )
@@ -1387,8 +1413,13 @@ bool CServer::SendChatTextToConChannel ( const int iCurChanID, const QString& st
{
return false;
}
- // send message
- vecChannels[iCurChanID].CreateChatTextMes ( strChatText );
+
+ // route the private message through the same fan-out logic as broadcast
+ // chat (RPC-originated: wire sentinel ID 255, no sender name)
+ const QString strClampedChatText = strChatText.left ( MAX_LEN_CHAT_TEXT );
+ const uint32_t iTimestamp = static_cast ( QDateTime::currentSecsSinceEpoch() );
+
+ SendChatTextToConChannelWithSource ( iCurChanID, SERVER_CHAT_CHANNEL_ID, iTimestamp, QString(), strClampedChatText );
return true;
}
diff --git a/src/server.h b/src/server.h
index c54c3083cd..1027d3b3e9 100644
--- a/src/server.h
+++ b/src/server.h
@@ -193,7 +193,7 @@ class CServer : public QObject, public CServerSlots
void SetEnableDelayPanning ( bool bDelayPanningOn ) { bDelayPan = bDelayPanningOn; }
bool IsDelayPanningEnabled() { return bDelayPan; }
- void SendChatTextToAllConChannels ( const int iSendingChanID, const QString& strChatText );
+ void CreateAndSendChatTextForAllConChannels ( const int iSendingChanID, const QString& strChatText );
bool SendChatTextToConChannel ( const int iCurChanID, const QString& strChatText );
protected:
@@ -209,7 +209,7 @@ class CServer : public QObject, public CServerSlots
virtual void CreateAndSendChanListForAllConChannels();
virtual void CreateAndSendChanListForThisChan ( const int iCurChanID );
- virtual void CreateAndSendChatTextForAllConChannels ( const int iCurChanID, const QString& strChatText );
+ void SendChatTextToConChannelWithSource ( const int iCurChanID, const int iSourceChanID, const uint32_t iTimestamp, const QString& strSenderName, const QString& strChatText );
virtual void CreateOtherMuteStateChanged ( const int iCurChanID, const int iOtherChanID, const bool bIsMuted );
@@ -331,7 +331,7 @@ class CServer : public QObject, public CServerSlots
void Stopped();
void ClientDisconnected ( const int iChID );
void ClientConnected ( const int iChID, const QHostAddress RecHostAddr, const int iTotChans );
- void sentChatMessage ( const int iSendingChanID, const QString& strChatText );
+ void sentChatMessage ( const int iSendingChanID, const uint32_t iTimestamp, const QString& strSenderName, const QString& strChatText );
void SvrRegStatusChanged();
void AudioFrame ( const int iChID,
const QString stChName,
diff --git a/src/serverrpc.cpp b/src/serverrpc.cpp
index 05bb54be62..2adc36bb39 100644
--- a/src/serverrpc.cpp
+++ b/src/serverrpc.cpp
@@ -47,9 +47,6 @@
#include "serverrpc.h"
-/* Definitions ****************************************************************/
-#define INVALID_CLIENT_ID -1
-
CServerRpc::CServerRpc ( CServer* pServer, CRpcServer* pRpcServer, QObject* parent ) : QObject ( parent )
{
// API doc already part of CClientRpc
@@ -85,13 +82,17 @@ CServerRpc::CServerRpc ( CServer* pServer, CRpcServer* pRpcServer, QObject* pare
/// @rpc_notification jamulusserver/chatMessageReceived
/// @brief Emitted when a chat message is received from either a Jamulus or RPC client and to be broadcast to all connected clients.
- /// @param {number} params.id - Channel ID of sending client or -1 for RPC sent messages.
- /// @param {string} params.chatMessage - Chat message text.
- connect ( pServer, &CServer::sentChatMessage, [=] ( const int iSendingChanID, const QString& strChatText ) {
+ /// @param {number} params.channelId - Channel ID of sending client or -1 for RPC sent messages.
+ /// @param {number} params.timestamp - Unix timestamp (seconds) stamped at the server.
+ /// @param {string} params.senderName - Name of the sending client (empty for RPC sent messages).
+ /// @param {string} params.text - Chat message text.
+ connect ( pServer, &CServer::sentChatMessage, [=] ( const int iSendingChanID, const uint32_t iTimestamp, const QString& strSenderName, const QString& strChatText ) {
pRpcServer->BroadcastNotification ( "jamulusserver/chatMessageReceived",
QJsonObject{
- { "id", iSendingChanID },
- { "chatMessage", strChatText },
+ { "channelId", iSendingChanID },
+ { "timestamp", static_cast ( iTimestamp ) },
+ { "senderName", strSenderName },
+ { "text", strChatText },
} );
} );
@@ -109,7 +110,7 @@ CServerRpc::CServerRpc ( CServer* pServer, CRpcServer* pRpcServer, QObject* pare
}
// set invalid channel ID to make clear this message was not sent by a Jamulus client
- pServer->SendChatTextToAllConChannels ( INVALID_CLIENT_ID, jsonChatMessage.toString() );
+ pServer->CreateAndSendChatTextForAllConChannels ( INVALID_CLIENT_ID, jsonChatMessage.toString() );
response["result"] = "ok";
} );
diff --git a/src/util.cpp b/src/util.cpp
index 39efa10a16..ec8a273d8c 100644
--- a/src/util.cpp
+++ b/src/util.cpp
@@ -46,6 +46,8 @@
#include "util.h"
+#include
+
namespace
{
// Capture layout:
@@ -1780,3 +1782,33 @@ QString TruncateString ( QString str, int position )
}
return str.left ( position );
}
+
+QString EscapeAndLinkifyText ( const QString strText )
+{
+ // escape first, then linkify, so that user text can never become markup
+ QString strEscapedText = strText.toHtmlEscaped();
+ LinkifyURLs ( strEscapedText );
+ return strEscapedText;
+}
+
+void LinkifyURLs ( QString& strText )
+{
+ // searches for all occurrences of http(s) and wraps them in anchor tags;
+ // must only be applied AFTER HTML-escaping (see EscapeAndLinkifyText)
+ // The regex contains three parts:
+ // - https?://(?:[^\s&]|&(?:amp|#\d+|#[xX][0-9a-fA-F]+);)+ matches as much as
+ // possible after the http:// or https://, stopping at whitespace and at a
+ // bare "&" that does not start an allowed HTML entity. Only & (and
+ // numeric entities) may appear inside a URL, so query strings such as
+ // ?a=1&b=2 survive escaping as ?a=1&b=2 and are kept whole, while
+ // escaped markup boundaries (< > ") always terminate the match.
+ // The last two parts exclude terminating punctuation.
+ // - (??\\[\\]{}]) is a negative look-behind assertion that disallows the match
+ // from ending with one of the characters !"'()+,.:;<=>?[]{}
+ // - (??\\[\\]{}]) is a negative look-behind assertion that disallows the match
+ // from ending with a ? followed by one of the characters !"'()+,.:;<=>?[]{}
+ // These last two parts must be separate, as a look-behind assertion must be fixed length.
+#define PUNCT_NOEND_URL "[!\"'()+,.:;<=>?\\[\\]{}]"
+ strText.replace ( QRegularExpression ( "(https?://(?:[^\\s&]|&(?:amp|#\\d+|#[xX][0-9a-fA-F]+);)+(?\\1" );
+}
diff --git a/src/util.h b/src/util.h
index 08e45166cb..e0f1706394 100644
--- a/src/util.h
+++ b/src/util.h
@@ -142,6 +142,12 @@ bool IsMappedReleaseVersion ( const QString& mappedVersion );
QString MapVersionStrForCompare ( const QString& versionStr );
QString TruncateString ( QString str, int position );
+// escape plain text as HTML and then wrap bare http(s):// URLs in anchor tags;
+// used for chat rendering so that user text is never interpreted as markup
+QString EscapeAndLinkifyText ( const QString strText );
+// wrap bare http(s):// URLs in anchor tags in already-escaped HTML text
+void LinkifyURLs ( QString& strText );
+
/******************************************************************************\
* CVector Base Class *
\******************************************************************************/
diff --git a/tests/chatprotocol/chatprotocol.pro b/tests/chatprotocol/chatprotocol.pro
new file mode 100644
index 0000000000..08b3e95d4d
--- /dev/null
+++ b/tests/chatprotocol/chatprotocol.pro
@@ -0,0 +1,25 @@
+QT += core network
+QT -= gui
+CONFIG += console
+CONFIG -= app_bundle
+TEMPLATE = app
+TARGET = chatprotocoltest
+
+DEFINES += HEADLESS
+DEFINES += APP_VERSION=\\\"3.12.3dev\\\"
+
+INCLUDEPATH += ../../src
+
+win32 {
+ DEFINES += NOMINMAX
+}
+
+SOURCES += \
+ tst_chatprotocol.cpp \
+ ../../src/protocol.cpp \
+ ../../src/util.cpp
+
+HEADERS += \
+ ../../src/protocol.h \
+ ../../src/util.h \
+ ../../src/chatmessage.h
diff --git a/tests/chatprotocol/tst_chatprotocol.cpp b/tests/chatprotocol/tst_chatprotocol.cpp
new file mode 100644
index 0000000000..d22eff79c3
--- /dev/null
+++ b/tests/chatprotocol/tst_chatprotocol.cpp
@@ -0,0 +1,490 @@
+/******************************************************************************\
+ * Standalone protocol tests for the redesigned chat message (Slice 1).
+ *
+ * Covers message 37 (structured chat text) create/evaluate round-trips and
+ * decode reject rules, plus the 38/39 capability handshake.
+ *
+ * Build (from the tests/chatprotocol directory):
+ * qmake && nmake release (or mingw32-make)
+ * Run:
+ * release/chatprotocoltest
+\******************************************************************************/
+
+#include
+#include
+
+#include
+#include
+#include
+#include
+
+#include "protocol.h"
+#include "util.h"
+#include "chatmessage.h"
+
+/* -------------------------------------------------------------------------- */
+// expose the protected stream helpers so that malformed bodies can be crafted
+class CProtocolExposed : public CProtocol
+{
+public:
+ using CProtocol::GetValFromStream;
+ using CProtocol::PutStringUTF8OnStream;
+ using CProtocol::PutValOnStream;
+};
+
+/* -------------------------------------------------------------------------- */
+// simple test framework
+static int iNumTests = 0;
+static int iNumFailures = 0;
+
+static void Check ( const bool bCondition, const char* strTestName )
+{
+ iNumTests++;
+ if ( bCondition )
+ {
+ std::printf ( "ok: %s\n", strTestName );
+ }
+ else
+ {
+ iNumFailures++;
+ std::printf ( "FAIL: %s\n", strTestName );
+ }
+}
+
+/* -------------------------------------------------------------------------- */
+// protocol harness: captures sent frames and received signals
+struct SChatReceived
+{
+ uint8_t iChannelID;
+ uint32_t iTimestamp;
+ QString strSenderName;
+ QString strChatText;
+};
+
+class CProtocolHarness
+{
+public:
+ CProtocolExposed Prot;
+ QVector> vecSentFrames;
+ QVector vecChatReceived;
+ int iReqChatTextSupport;
+ int iChatTextSupported;
+
+ CProtocolHarness()
+ {
+ iReqChatTextSupport = 0;
+ iChatTextSupported = 0;
+
+ QObject::connect ( &Prot, &CProtocol::MessReadyForSending,
+ [this] ( CVector vecMessage ) { vecSentFrames.append ( vecMessage ); } );
+
+ QObject::connect ( &Prot, &CProtocol::ChatTextChannelReceived,
+ [this] ( uint8_t iChannelID, uint32_t iTimestamp, QString strSenderName, QString strChatText ) {
+ vecChatReceived.append ( SChatReceived{ iChannelID, iTimestamp, strSenderName, strChatText } );
+ } );
+
+ QObject::connect ( &Prot, &CProtocol::ReqChatTextSupport, [this]() { iReqChatTextSupport++; } );
+
+ QObject::connect ( &Prot, &CProtocol::ChatTextSupported, [this]() { iChatTextSupported++; } );
+ }
+
+ void Reset()
+ {
+ vecSentFrames.clear();
+ vecChatReceived.clear();
+ iReqChatTextSupport = 0;
+ iChatTextSupported = 0;
+ Prot.Reset();
+ }
+};
+
+// Deliver every frame Tx emitted (immediately or after the simulated ACK
+// handshake, which is required to drain split messages) to Rx, so that Rx
+// fully reassembles split messages and evaluates them.
+static void DeliverAll ( CProtocolHarness& Tx, CProtocolHarness& Rx )
+{
+ int iTxFrameIdx = 0;
+ int iRxFrameIdx = 0;
+
+ while ( true )
+ {
+ bool bProgress = false;
+
+ // deliver any new frames from Tx to Rx
+ for ( ; iTxFrameIdx < Tx.vecSentFrames.size(); iTxFrameIdx++ )
+ {
+ CVector vecBody;
+ int iCnt = 0;
+ int iID = 0;
+
+ if ( CProtocol::ParseMessageFrame ( Tx.vecSentFrames[iTxFrameIdx], Tx.vecSentFrames[iTxFrameIdx].Size(), vecBody, iCnt, iID ) )
+ {
+ continue;
+ }
+ Rx.Prot.ParseMessageBody ( vecBody, iCnt, iID );
+ bProgress = true;
+ }
+
+ // feed ACKs Rx emitted back to Tx (each ACK drains one queued message)
+ for ( ; iRxFrameIdx < Rx.vecSentFrames.size(); iRxFrameIdx++ )
+ {
+ CVector vecBody;
+ int iCnt = 0;
+ int iID = 0;
+
+ if ( CProtocol::ParseMessageFrame ( Rx.vecSentFrames[iRxFrameIdx], Rx.vecSentFrames[iRxFrameIdx].Size(), vecBody, iCnt, iID ) )
+ {
+ continue;
+ }
+ if ( iID == PROTMESSID_ACKN )
+ {
+ Tx.Prot.ParseMessageBody ( vecBody, iCnt, iID );
+ bProgress = true;
+ }
+ }
+
+ if ( !bProgress )
+ {
+ break;
+ }
+ }
+}
+
+/* -------------------------------------------------------------------------- */
+int main ( int argc, char* argv[] )
+{
+ QCoreApplication app ( argc, argv );
+
+ CProtocolHarness Tx;
+ CProtocolHarness Rx;
+
+ // --- message 37 round-trips (non-split) --------------------------------
+ {
+ const uint8_t iChannelID = 7;
+ const uint32_t iTimestamp = 1786298460U;
+ const QString strName = "Alice";
+ const QString strText = "hello";
+
+ Tx.Prot.CreateChatTextChannelMes ( iChannelID, iTimestamp, strName, strText );
+ DeliverAll ( Tx, Rx );
+
+ Check ( Rx.vecChatReceived.size() == 1, "37 round-trip: exactly one message received" );
+ if ( Rx.vecChatReceived.size() == 1 )
+ {
+ Check ( Rx.vecChatReceived[0].iChannelID == iChannelID, "37 round-trip: channel ID" );
+ Check ( Rx.vecChatReceived[0].iTimestamp == iTimestamp, "37 round-trip: timestamp" );
+ Check ( Rx.vecChatReceived[0].strSenderName == strName, "37 round-trip: sender name" );
+ Check ( Rx.vecChatReceived[0].strChatText == strText, "37 round-trip: text" );
+ }
+ Tx.Reset();
+ Rx.Reset();
+ }
+
+ // UTF-8 text and name
+ {
+ const uint8_t iChannelID = 3;
+ const uint32_t iTimestamp = 1700000000U;
+ const QString strName = QString::fromUtf8 ( "\xc3\x9c""n\xc3\xaf""code \xe2\x98\xba" ); // "Ünïcode ☺"
+ const QString strText = QString::fromUtf8 ( "h\xc3\xa9""llo w\xc3\xb6""rld \xe6\x97\xa5\xe6\x9c\xac\xe8\xaa\x9e" ); // "héllo wörld 日本語"
+
+ Tx.Prot.CreateChatTextChannelMes ( iChannelID, iTimestamp, strName, strText );
+ DeliverAll ( Tx, Rx );
+
+ Check ( Rx.vecChatReceived.size() == 1, "37 UTF-8: message received" );
+ if ( Rx.vecChatReceived.size() == 1 )
+ {
+ Check ( Rx.vecChatReceived[0].iChannelID == iChannelID, "37 UTF-8: channel ID" );
+ Check ( Rx.vecChatReceived[0].iTimestamp == iTimestamp, "37 UTF-8: timestamp" );
+ Check ( Rx.vecChatReceived[0].strSenderName == strName, "37 UTF-8: sender name" );
+ Check ( Rx.vecChatReceived[0].strChatText == strText, "37 UTF-8: text" );
+ }
+ Tx.Reset();
+ Rx.Reset();
+ }
+
+ // empty name and empty text
+ {
+ Tx.Prot.CreateChatTextChannelMes ( 0, 0U, QString(), QString() );
+ DeliverAll ( Tx, Rx );
+
+ Check ( Rx.vecChatReceived.size() == 1, "37 empty: message received" );
+ if ( Rx.vecChatReceived.size() == 1 )
+ {
+ Check ( Rx.vecChatReceived[0].iChannelID == 0, "37 empty: channel ID" );
+ Check ( Rx.vecChatReceived[0].iTimestamp == 0U, "37 empty: timestamp" );
+ Check ( Rx.vecChatReceived[0].strSenderName.isEmpty(), "37 empty: sender name" );
+ Check ( Rx.vecChatReceived[0].strChatText.isEmpty(), "37 empty: text" );
+ }
+ Tx.Reset();
+ Rx.Reset();
+ }
+
+ // boundary channel IDs (0, 149 and the 255 server/RPC sentinel)
+ {
+ const uint8_t aiChannelIDs[3] = { 0, MAX_NUM_CHANNELS - 1, SERVER_CHAT_CHANNEL_ID };
+ const char* astrChannelNames[3] = { "channel 0", "channel 149", "channel 255" };
+
+ for ( int i = 0; i < 3; i++ )
+ {
+ Tx.Prot.CreateChatTextChannelMes ( aiChannelIDs[i], 100U, "n", "t" );
+ DeliverAll ( Tx, Rx );
+
+ Check ( ( Rx.vecChatReceived.size() == 1 ) && ( Rx.vecChatReceived[0].iChannelID == aiChannelIDs[i] ),
+ astrChannelNames[i] );
+ Tx.Reset();
+ Rx.Reset();
+ }
+ }
+
+ // maximum length text (MAX_LEN_CHAT_TEXT == 1600), sent unsplit
+ {
+ const QString strText ( MAX_LEN_CHAT_TEXT, QChar ( 'a' ) );
+ Tx.Prot.CreateChatTextChannelMes ( 1, 100U, "n", strText );
+ DeliverAll ( Tx, Rx );
+
+ Check ( ( Rx.vecChatReceived.size() == 1 ) && ( Rx.vecChatReceived[0].strChatText == strText ),
+ "37 max length text round-trip" );
+ Tx.Reset();
+ Rx.Reset();
+ }
+
+ // large text which takes the split message path (body > MESS_SPLIT_PART_SIZE_BYTES)
+ {
+ Tx.Prot.SetSplitMessageSupported ( true );
+ const QString strText ( MAX_LEN_CHAT_TEXT, QChar ( 'x' ) );
+ Tx.Prot.CreateChatTextChannelMes ( 5, 200U, "split-test", strText );
+ DeliverAll ( Tx, Rx );
+
+ Check ( Rx.vecChatReceived.size() == 1, "37 split: message reassembled" );
+ if ( Rx.vecChatReceived.size() == 1 )
+ {
+ Check ( Rx.vecChatReceived[0].iChannelID == 5, "37 split: channel ID" );
+ Check ( Rx.vecChatReceived[0].iTimestamp == 200U, "37 split: timestamp" );
+ Check ( Rx.vecChatReceived[0].strSenderName == "split-test", "37 split: sender name" );
+ Check ( Rx.vecChatReceived[0].strChatText == strText, "37 split: text" );
+ }
+ Tx.Reset();
+ Rx.Reset();
+ }
+
+ // --- ChatMessage data model (Slice 2) -----------------------------------
+ {
+ // a ChatMessage carries the wire facts verbatim: channel ID, epoch
+ // timestamp, the sender name snapshot and plain text (never HTML)
+ ChatMessage msg{ 12, 1786298460U, "Alice", "hello & " };
+
+ Check ( msg.channelId == 12, "ChatMessage: channel ID" );
+ Check ( msg.timestamp == 1786298460U, "ChatMessage: timestamp" );
+ Check ( msg.senderName == "Alice", "ChatMessage: sender name" );
+ Check ( msg.text == "hello & ", "ChatMessage: text is plain data, not markup" );
+
+ // text never silently changes representation between wire and model
+ const QString strText = QString::fromUtf8 ( "h\xc3\xa9""llo w\xc3\xb6""rld \xe6\x97\xa5\xe6\x9c\xac\xe8\xaa\x9e" ); // "héllo wörld 日本語"
+ msg.text = strText;
+ Check ( msg.text == strText, "ChatMessage: UTF-8 text preserved" );
+
+ // the wire fields of a message 37 round-trip map 1:1 into a ChatMessage
+ {
+ const uint8_t iChannelID = 9;
+ const uint32_t iTimestamp = 1786300000U;
+ const QString strName = "Bob";
+ const QString strText = "round trip";
+
+ Tx.Prot.CreateChatTextChannelMes ( iChannelID, iTimestamp, strName, strText );
+ DeliverAll ( Tx, Rx );
+
+ bool bMapped = ( Rx.vecChatReceived.size() == 1 );
+ if ( bMapped )
+ {
+ const ChatMessage msgRoundTrip{ Rx.vecChatReceived[0].iChannelID,
+ Rx.vecChatReceived[0].iTimestamp,
+ Rx.vecChatReceived[0].strSenderName,
+ Rx.vecChatReceived[0].strChatText };
+ bMapped = ( msgRoundTrip.channelId == iChannelID ) && ( msgRoundTrip.timestamp == iTimestamp ) &&
+ ( msgRoundTrip.senderName == strName ) && ( msgRoundTrip.text == strText );
+ }
+ Check ( bMapped, "ChatMessage: wire fields map 1:1 into the data model" );
+ Tx.Reset();
+ Rx.Reset();
+ }
+
+ // server/RPC-originated messages carry the wire sentinel channel ID
+ {
+ const ChatMessage msgServer{ SERVER_CHAT_CHANNEL_ID, 100U, QString(), "server note" };
+ Check ( msgServer.channelId == 255, "ChatMessage: server/RPC sentinel channel ID" );
+ Check ( msgServer.senderName.isEmpty(), "ChatMessage: server/RPC messages have no sender name" );
+ }
+ }
+
+ // --- safe chat text rendering (Slice 3) ---------------------------------
+ // invariant: user text is escaped before linkification and can never become
+ // executable or unintended HTML
+ {
+ // markup is escaped, never interpreted
+ const QString strEscapedBold = EscapeAndLinkifyText ( "hello" );
+ Check ( !strEscapedBold.contains ( "" ) && strEscapedBold.contains ( "<b>hello</b>" ),
+ "safe render: is escaped" );
+
+ const QString strEscapedImg = EscapeAndLinkifyText ( "
" );
+ Check ( !strEscapedImg.contains ( "
is escaped" );
+
+ const QString strEscapedScript = EscapeAndLinkifyText ( "" );
+ Check ( !strEscapedScript.contains ( "