touch: a long name no longer pushes the author into the message body

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 <ruben@tun0.nl>
This commit is contained in:
Ruben Laban committed 2026-09-01 07:53:14 +02:00
1 parent b877e43d5e
commit 78ca05f5bd
3 files changed
+164 -20

No files matched your search

+62
View File
@@ -0,0 +1,62 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <string.h>
// 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<size_t>(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<uint8_t>(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
+7 -20
View File
@@ -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 <lvgl.h>
@@ -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<int>(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<int>(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
+95
View File
@@ -0,0 +1,95 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include <assert.h>
#include <string.h>
#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;
}