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:
3 files changed
+164
-20
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user