From 78ca05f5bdcc2740b7d564caafababbf7aca668b Mon Sep 17 00:00:00 2001 From: Ruben Laban Date: Mon, 31 Aug 2026 20:10:49 +0200 Subject: [PATCH] touch: a long name no longer pushes the author into the message body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A channel post arrives on the wire as "SenderName: body" — the author is in the text, not a separate field. Both split sites gave up when the name was longer than MAX_SENDER_NAME (24), and giving up meant the sender fell back to from_name, which for a channel is the CHANNEL name. The bubble then read #channel date nick: message instead of the author over their own message. MeshCore names run to 31 chars (ContactInfo::name[32]), so this hit every post from a node with a 25+ char name — the "occasionally" in the report. Truncate the label instead of rejecting the split, backing off to a UTF-8 character boundary so a multi-byte name is never cut mid-sequence (the missing-glyph sanitiser would render the tail as '*'), and mark the cut with "..." so a shortened name does not read as a different node. Widening MAX_SENDER_NAME is the real cure but not an option here: sender[] sits in the MIDDLE of the persisted UiHistoryMsg record, and this file format only tolerates fields APPENDED to the end — moving `text` would force k_ui_history_min_version up and discard everyone's chat history. The two copies of the split are now one host-tested header, matching how Utf8Text/ReaderContent are factored. Signed-off-by: Ruben Laban --- src/ui-touch/ChannelSenderSplit.h | 62 +++++++++++++++++++ src/ui-touch/UITask.cpp | 27 +++------ test/test_channel_sender_split.cpp | 95 ++++++++++++++++++++++++++++++ 3 files changed, 164 insertions(+), 20 deletions(-) create mode 100644 src/ui-touch/ChannelSenderSplit.h create mode 100644 test/test_channel_sender_split.cpp diff --git a/src/ui-touch/ChannelSenderSplit.h b/src/ui-touch/ChannelSenderSplit.h new file mode 100644 index 0000000..5fb836e --- /dev/null +++ b/src/ui-touch/ChannelSenderSplit.h @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include + +// A channel/group post arrives on the wire as "SenderName: body" — the author is +// part of the text, not a separate field (unlike a DM, where the sender rides +// along as from_name). Split it so the UI can label the bubble with the author +// and show only the body underneath. +namespace ChannelSenderSplit { + +// Copies the embedded author into `sender_out` (always NUL-terminated, empty when +// there is nothing to split) and returns the start of the body — `text` itself +// when the string carries no author prefix. +// +// A name longer than the destination is TRUNCATED, not rejected. MeshCore names +// run to 31 chars while UIMessage::sender holds 24, and refusing the split for +// the overflowing ones is what put the channel name in the bubble header and left +// "nick: message" in the body. +// +// A truncated name ends in "..." (ASCII, like chatFitLeadingEllipsis — the baked +// fonts carry no U+2026), so a cut name reads as cut rather than as a different +// node with a shorter name. The marker is part of the stored sender, so the +// bubble label, the chat-list preview and block-by-name all agree on one string. +inline const char* split(const char* text, char* sender_out, size_t sender_cap) { + if (sender_out && sender_cap > 0) sender_out[0] = '\0'; + if (!text) return ""; + if (!sender_out || sender_cap < 2) return text; + + const char* colon = strstr(text, ": "); + if (!colon || colon == text) return text; + + static const char kEllipsis[] = "..."; + const size_t kEllipsisLen = sizeof(kEllipsis) - 1; + + const size_t cap = sender_cap - 1; // bytes available, NUL excluded + size_t slen = static_cast(colon - text); + const bool truncated = slen > cap; + // Only mark when the name still gets at least as many bytes as the marker — + // on a destination too small for that, "nick" says more than "n...". + const bool mark = truncated && cap >= 2 * kEllipsisLen; + if (truncated) { + slen = mark ? cap - kEllipsisLen : cap; + // Never cut a multi-byte character in half: step back to the start of the + // sequence the cap lands inside, so the label gets valid UTF-8 rather than a + // stray '*' from the missing-glyph sanitiser. + while (slen > 0 && (static_cast(text[slen]) & 0xC0U) == 0x80U) --slen; + if (slen == 0) return text; // one long sequence and nothing fits — leave it whole + } + + memcpy(sender_out, text, slen); + if (mark) { + memcpy(sender_out + slen, kEllipsis, kEllipsisLen); + slen += kEllipsisLen; + } + sender_out[slen] = '\0'; + return colon + 2; +} + +} // namespace ChannelSenderSplit diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index b1f40fa..f1e2840 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -127,6 +127,7 @@ static void* wadaMp3Scratch() { return s_wada_mp3_scratch; } #include "AppPage.h" // shared full-screen app-page chrome (both of the above use it) #include "ReaderContent.h" // host-tested HTML text extraction + local/network link resolution +#include "ChannelSenderSplit.h" // host-tested "SenderName: body" split for channel/room posts #if defined(HAS_TOUCH_UI) #include @@ -33576,15 +33577,10 @@ static void chatParseMessageDisplay(const UITask::UIMessage& m, bool channel_mod (channel_mode && (m.sender[0] == '\0' || (m.sender[0] == 'r' && m.sender[1] == 'x' && m.sender[2] == '\0'))))) { - const char* colon = strstr(m.text, ": "); - if (colon) { - const int slen = static_cast(colon - m.text); - if (slen > 0 && slen <= UITask::MAX_SENDER_NAME) { - strncpy(d.retro_sender, m.text, slen); - d.retro_sender[slen] = '\0'; - d.show_sender = d.retro_sender; - d.show_text = colon + 2; - } + const char* body = ChannelSenderSplit::split(m.text, d.retro_sender, sizeof(d.retro_sender)); + if (d.retro_sender[0]) { + d.show_sender = d.retro_sender; + d.show_text = body; } } copyUtf8ReplacingMissingGlyphs(&g_font_12, d.san_sender, sizeof(d.san_sender), d.show_sender); @@ -53598,17 +53594,8 @@ void UITask::newMsgImpl(uint8_t path_len, const char* from_name, const char* tex char parsed_sender[MAX_SENDER_NAME + 1]; parsed_sender[0] = '\0'; const char* body = text ? text : ""; - if (channel && text && !have_sender_override) { - const char* colon = strstr(text, ": "); - if (colon) { - int slen = static_cast(colon - text); - if (slen > 0 && slen <= MAX_SENDER_NAME) { - strncpy(parsed_sender, text, slen); - parsed_sender[slen] = '\0'; - body = colon + 2; - } - } - } + if (channel && text && !have_sender_override) + body = ChannelSenderSplit::split(text, parsed_sender, sizeof(parsed_sender)); const char* sender = have_sender_override ? sender_override : (channel diff --git a/test/test_channel_sender_split.cpp b/test/test_channel_sender_split.cpp new file mode 100644 index 0000000..2d712b4 --- /dev/null +++ b/test/test_channel_sender_split.cpp @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include + +#include "ui-touch/ChannelSenderSplit.h" + +// UIMessage::sender is char[MAX_SENDER_NAME + 1] with MAX_SENDER_NAME == 24. +static const size_t kSenderCap = 25; + +int main() { + char sender[kSenderCap]; + + // Ordinary channel post: author out, body in. + const char* body = ChannelSenderSplit::split("nick: hello there", sender, sizeof sender); + assert(strcmp(sender, "nick") == 0); + assert(strcmp(body, "hello there") == 0); + + // The regression: a 27-char MeshCore name (they run to 31) used to fail the + // split outright, so the bubble header showed the channel and the body kept + // reading "nick: message". It must split, truncating the label instead — and a + // truncated label ends in "..." so it does not read as a shorter name. + const char* long_name = "Ouderkerk Repeater Node Two: on air"; + body = ChannelSenderSplit::split(long_name, sender, sizeof sender); + assert(strlen(sender) == 24); + assert(strcmp(sender, "Ouderkerk Repeater No...") == 0); + assert(strcmp(body, "on air") == 0); + + // Truncation lands on a character boundary, never inside a multi-byte sequence. + // "Ouderkerkk" (10) + U+2600 (3 bytes) x 5 = 25 bytes; the 21-byte name budget + // (24 minus the marker) falls inside the fourth sun, so it backs off to 19. + const char* utf8_name = "Ouderkerkk\xE2\x98\x80\xE2\x98\x80\xE2\x98\x80\xE2\x98\x80\xE2\x98\x80: hi"; + body = ChannelSenderSplit::split(utf8_name, sender, sizeof sender); + assert(strcmp(sender, "Ouderkerkk\xE2\x98\x80\xE2\x98\x80\xE2\x98\x80...") == 0); + assert(strlen(sender) == 22); + assert(strcmp(body, "hi") == 0); + + // Same shape one char shorter, so the budget already sits on a boundary: the + // back-off must not eat a whole character that fits. + const char* utf8_fits = "Ouderkerk\xE2\x98\x80\xE2\x98\x80\xE2\x98\x80\xE2\x98\x80\xE2\x98\x80\xE2\x98\x80: hi"; + body = ChannelSenderSplit::split(utf8_fits, sender, sizeof sender); + assert(strcmp(sender, "Ouderkerk\xE2\x98\x80\xE2\x98\x80\xE2\x98\x80\xE2\x98\x80...") == 0); + assert(strlen(sender) == 24); + assert(strcmp(body, "hi") == 0); + + // One char over the cap still gets the marker (name budget 21, not 24). + const char* one_over = "1234567890123456789012345: body"; + body = ChannelSenderSplit::split(one_over, sender, sizeof sender); + assert(strcmp(sender, "123456789012345678901...") == 0); + assert(strcmp(body, "body") == 0); + + // Destination too small to hold a marker: truncate plainly rather than + // spending the whole label on dots. + char tiny[5]; + body = ChannelSenderSplit::split("nickname: body", tiny, sizeof tiny); + assert(strcmp(tiny, "nick") == 0); + assert(strcmp(body, "body") == 0); + + // Exactly at the cap: 24 chars, no truncation. + const char* exact = "123456789012345678901234: body"; + body = ChannelSenderSplit::split(exact, sender, sizeof sender); + assert(strcmp(sender, "123456789012345678901234") == 0); + assert(strcmp(body, "body") == 0); + + // No author prefix — text passes through untouched, sender stays empty so the + // caller falls back to from_name. + const char* plain = "just a message"; + body = ChannelSenderSplit::split(plain, sender, sizeof sender); + assert(sender[0] == '\0'); + assert(body == plain); + + // A bare colon without the space is not a separator. + const char* no_space = "nick:hello"; + body = ChannelSenderSplit::split(no_space, sender, sizeof sender); + assert(sender[0] == '\0'); + assert(body == no_space); + + // Leading separator: empty author is not a name. + const char* leading = ": body"; + body = ChannelSenderSplit::split(leading, sender, sizeof sender); + assert(sender[0] == '\0'); + assert(body == leading); + + // First separator wins — a colon in the body does not re-split. + body = ChannelSenderSplit::split("nick: ratio 3: 1", sender, sizeof sender); + assert(strcmp(sender, "nick") == 0); + assert(strcmp(body, "ratio 3: 1") == 0); + + // Degenerate inputs. + assert(strcmp(ChannelSenderSplit::split(nullptr, sender, sizeof sender), "") == 0); + assert(sender[0] == '\0'); + const char* keep = "nick: hi"; + assert(ChannelSenderSplit::split(keep, nullptr, 0) == keep); + return 0; +}