mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-07-28 07:48:13 +00:00
fix(ui,bot): resolve remaining findings from the 2026-07-26 screen review
- DM/room unread badges could claim messages the ring no longer held
(same class as the channel fix in 6470afaf, not covered by it):
getDMUnread()/getDMUnreadTotal() now clamp to dmHistCountForContact(),
and a new reconcileDMUnread() (called once per loop()) frees any
_dm_unread_table slot whose ring occupancy has dropped to 0, so a
17th sender isn't starved by stale entries. onContactRemoved() now
also clears _dm_unread_table -- the one per-contact table it was
missing.
- Shift didn't capitalise ł/ń/ź/ż (+ĺ/ľ/ň/ž): the Latin Extended-A
case-pairing rule assumed a single parity for the whole block, but it
flips around the unpaired codepoints ĸ/ʼn/Ÿ. Fixed with four
sub-ranges, verified exhaustively over U+0100-U+017F.
- Triple-click could still toggle the buzzer while locked on
PIN_USER_BTN/PIN_USER_BTN_ANA boards (joystick path already guarded
this).
- millis() wraparound: 4 absolute comparisons in UITask.cpp (battery
poll, auto-off, lock-wake, backlight) converted to the existing
(int32_t)(millis()-deadline)>=0 idiom; MyMeshBot.h's DM-throttle
eviction now picks the oldest slot by elapsed time instead of raw
t_ms, which picked the wrong slot right after a rollover.
- Long-press bypassed checkDisplayOn() on all 5 call sites -- neither
woke the display nor extended auto-off, and could deliver
KEY_CONTEXT_MENU to the invisible screen. Moved the gate inside
handleLongPress() itself instead of patching each site.
- CardKB's backspace/printable-insert branches didn't reset t9_cell,
so typing right after a T9 cycle tap could get silently overwritten
by a same-cell re-tap within the T9 timeout.
- buildContactList()'s counts[MAX_CONTACTS] was a 1400 B int array on
the 4 KB loop() stack; values are bounded by DM_HIST_MAX (32), so
now uint8_t.
- ACK table treated ack==0 as a wildcard: isAckPending(0) matched any
free slot, and processAck() with an all-zero ACK matched the first
free slot and returned its stale contact pointer. Both now skip/reject
ack==0, and the matched slot's contact pointer is cleared alongside
its ack hash.
- ensurePageOrderInit() could write one byte past page_order[13] when
migrating a saved order with all 13 slots full and CLOCK last --
guarded on insert_at < PAGE_ORDER_LEN.
Two findings from the same review were resolved as no-op decisions,
not code changes: !buzz over DM ignoring quiet hours is intentional
(the pull exemption is meant to cover the buzzer), and the offline
queue's full-queue drop-newest behaviour is upstream code, left alone.
Build-verified green on WioTrackerL1_companion_solo_dual (RAM 71.1%,
Flash 66.6%) and WioTrackerL1Eink_companion_solo_dual (RAM 73.0%,
Flash 67.9%).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -462,8 +462,15 @@ void MyMesh::onContactPathUpdated(const ContactInfo &contact) {
|
||||
}
|
||||
|
||||
ContactInfo* MyMesh::processAck(const uint8_t *data) {
|
||||
// 0 marks an empty/cleared table slot, not a real ACK -- a bare memcmp below
|
||||
// would otherwise match the first free slot against an all-zero `data` (which
|
||||
// *is* remotely reachable, ack_crc comes off the air) and misattribute the
|
||||
// ACK to whatever contact that slot last belonged to.
|
||||
static const uint8_t zero_ack[4] = {0, 0, 0, 0};
|
||||
if (memcmp(data, zero_ack, 4) == 0) return checkConnectionsAck(data);
|
||||
// see if matches any in a table
|
||||
for (int i = 0; i < EXPECTED_ACK_TABLE_SIZE; i++) {
|
||||
if (expected_ack_table[i].ack == 0) continue; // empty slot
|
||||
if (memcmp(data, &expected_ack_table[i].ack, 4) == 0) { // got an ACK from recipient
|
||||
out_frame[0] = PUSH_CODE_SEND_CONFIRMED;
|
||||
memcpy(&out_frame[1], data, 4);
|
||||
@@ -471,9 +478,11 @@ ContactInfo* MyMesh::processAck(const uint8_t *data) {
|
||||
memcpy(&out_frame[5], &trip_time, 4);
|
||||
_serial->writeFrame(out_frame, 9);
|
||||
|
||||
ContactInfo* contact = expected_ack_table[i].contact;
|
||||
// NOTE: the same ACK can be received multiple times!
|
||||
expected_ack_table[i].ack = 0; // clear expected hash, now that we have received ACK
|
||||
return expected_ack_table[i].contact;
|
||||
expected_ack_table[i].ack = 0; // clear expected hash, now that we have received ACK
|
||||
expected_ack_table[i].contact = nullptr; // and the stale pointer along with it
|
||||
return contact;
|
||||
}
|
||||
}
|
||||
return checkConnectionsAck(data);
|
||||
|
||||
@@ -298,8 +298,9 @@ public:
|
||||
void applyRepeaterRadio();
|
||||
|
||||
bool isAckPending(uint32_t expected_ack) const {
|
||||
if (expected_ack == 0) return false; // 0 marks an empty/cleared slot, not a real ACK
|
||||
for (int i = 0; i < EXPECTED_ACK_TABLE_SIZE; i++)
|
||||
if (expected_ack_table[i].ack == expected_ack) return true;
|
||||
if (expected_ack_table[i].ack != 0 && expected_ack_table[i].ack == expected_ack) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -107,9 +107,11 @@ void MyMesh::botDmRecord(const uint8_t* pubkey) {
|
||||
for (int i = 0; i < BOT_DM_LOG_SIZE; i++)
|
||||
if (!_bot_dm_log[i].used) { target = i; break; }
|
||||
if (target < 0) { // ...or evict the oldest
|
||||
target = 0;
|
||||
for (int i = 1; i < BOT_DM_LOG_SIZE; i++)
|
||||
if (_bot_dm_log[i].t_ms < _bot_dm_log[target].t_ms) target = i;
|
||||
target = 0; // compare by elapsed-since-now (wraparound-safe),
|
||||
for (int i = 1; i < BOT_DM_LOG_SIZE; i++) // not raw t_ms -- a bare '<' picks the wrong slot
|
||||
if ((int32_t)(now - _bot_dm_log[i].t_ms) > // right after millis() rolls over (new entries
|
||||
(int32_t)(now - _bot_dm_log[target].t_ms)) // then have small raw values, not large ones)
|
||||
target = i;
|
||||
}
|
||||
memcpy(_bot_dm_log[target].key, pubkey, 4);
|
||||
_bot_dm_log[target].t_ms = now;
|
||||
|
||||
@@ -126,11 +126,12 @@ static const int KB_PREVIEW_CAP = 46;
|
||||
// special-cased before the range rule.
|
||||
// - Latin Extended-A (ą/č/ĺ/œ/etc., U+0100-017F): NOT a flat offset like
|
||||
// Latin-1 — this block alternates even=uppercase/odd=lowercase in adjacent
|
||||
// pairs, so lowercase - 1 = uppercase. Verified true (against Python's own
|
||||
// str.upper()) for every character actually used across the Polish/Czech/
|
||||
// Slovak/French keyboards below; NOT a universal rule for the whole block
|
||||
// (it has a handful of unpaired/irregular codepoints elsewhere) — recheck
|
||||
// before adding more from it.
|
||||
// pairs, so lowercase - 1 = uppercase. But the parity flips around the
|
||||
// unpaired codepoints ĸ (U+0138), ʼn (U+0149) and Ÿ (U+0178), so a single
|
||||
// "odd=lower" rule is wrong for U+0139-0148 and U+0179-017E (this broke
|
||||
// ł ń ź ż among others). Verified exhaustively over the whole
|
||||
// U+0100-U+017F block — see the four sub-ranges below; ı and ſ are the
|
||||
// only remaining exceptions and appear in no keyboard table here.
|
||||
// - Greek α-ω (U+03B1-03C9): flat -0x20 offset, same shape as Cyrillic/ASCII.
|
||||
// Final sigma ς (U+03C2) is the one exception — it has no uppercase of its
|
||||
// own; -0x20 would land on U+03A2, which is unassigned. It capitalizes to
|
||||
@@ -149,7 +150,13 @@ static void kbApplyCapsUtf8(const char* in, bool caps, char* out, size_t out_siz
|
||||
else if (cp >= 0x0430 && cp <= 0x044F) cp -= 0x20; // а-я -> А-Я
|
||||
else if (cp >= 0x03B1 && cp <= 0x03C9) cp -= 0x20; // α-ω -> Α-Ω
|
||||
else if (cp >= 0x00E0 && cp <= 0x00FE && cp != 0x00F7) cp -= 0x20; // à-þ -> À-Þ
|
||||
else if (cp >= 0x0100 && cp < 0x0180 && (cp & 1) == 1) cp -= 1; // ą-ż (odd=lower) -> Ą-Ż
|
||||
// ą-ż (Latin Extended-A): pairing parity flips around the unpaired
|
||||
// codepoints ĸ (U+0138), ʼn (U+0149), Ÿ (U+0178) -- verified exhaustively
|
||||
// over the whole U+0100-U+017F block, resolves ł ń ź ż + ĺ ľ ň ž too.
|
||||
else if (cp >= 0x0100 && cp <= 0x0137 && (cp & 1) == 1) cp -= 1; // odd=lower
|
||||
else if (cp >= 0x0139 && cp <= 0x0148 && (cp & 1) == 0) cp -= 1; // even=lower
|
||||
else if (cp >= 0x014A && cp <= 0x0177 && (cp & 1) == 1) cp -= 1; // odd=lower
|
||||
else if (cp >= 0x0179 && cp <= 0x017E && (cp & 1) == 0) cp -= 1; // even=lower
|
||||
else if (cp >= 'a' && cp <= 'z') cp -= 0x20; // a-z -> A-Z
|
||||
}
|
||||
if (cp < 0x80) {
|
||||
@@ -778,6 +785,7 @@ struct KeyboardWidget {
|
||||
// user, so there's no ambiguity to resolve here.
|
||||
if (c == KEY_KB_ENTER) return DONE;
|
||||
if (c == 0x08) {
|
||||
t9_cell = -1; // invalidate any pending T9 cycle -- see the grid paths below
|
||||
if (cursor_pos > 0) {
|
||||
int n = kbUtf8LastCharBytes(buf, cursor_pos);
|
||||
memmove(buf + cursor_pos - n, buf + cursor_pos, len - cursor_pos);
|
||||
@@ -787,6 +795,8 @@ struct KeyboardWidget {
|
||||
return NONE;
|
||||
}
|
||||
if (c >= 0x20 && c <= 0x7E) {
|
||||
t9_cell = -1; // otherwise a same-cell T9 tap within KB_T9_TIMEOUT_MS would
|
||||
// overwrite this character instead of inserting a new one
|
||||
char one[2] = { c, '\0' };
|
||||
insertGlyph(one, false);
|
||||
return NONE;
|
||||
|
||||
@@ -483,7 +483,10 @@ class MessagesScreen : public UIScreen {
|
||||
} else {
|
||||
bool show_all = (p && p->dm_show_all);
|
||||
// Build _sorted and counts[] in one pass — avoids a second getContactByIdx loop.
|
||||
int counts[MAX_CONTACTS];
|
||||
// uint8_t, not int: values are bounded by MessageHistory::DM_HIST_MAX (32), and
|
||||
// this array is 1400 B at this build's MAX_CONTACTS=350 as an int[] -- a sizeable
|
||||
// slice of the 4 KB loop() task stack for one local array.
|
||||
uint8_t counts[MAX_CONTACTS];
|
||||
for (int i = 0; i < total; i++) {
|
||||
if (!the_mesh.getContactByIdx(i, c) || c.type != ADV_TYPE_CHAT) continue;
|
||||
if (!show_all && !(c.flags & 0x01)) continue;
|
||||
@@ -774,6 +777,11 @@ public:
|
||||
|
||||
int getTotalChannelUnread() const { return _history.getTotalChannelUnread(); }
|
||||
|
||||
// How many DM ring entries this contact/room currently holds -- lets UITask
|
||||
// clamp its separate _dm_unread_table counters to what the shared 32-slot DM
|
||||
// ring actually still has, the same self-healing shape as the channel fix.
|
||||
int dmHistCountForContact(const uint8_t* pub_key) const { return _history.dmHistCountForContact(pub_key); }
|
||||
|
||||
void markReadAlert(int n) {
|
||||
char msg[32];
|
||||
snprintf(msg, sizeof(msg), "%d marked read", n);
|
||||
|
||||
@@ -323,9 +323,16 @@ class SettingsScreen : public UIScreen {
|
||||
// PAGE_ORDER_LEN so there's room; only a pathologically full order would
|
||||
// drop its last entry, which buildVisibleOrder's fallback re-appends.
|
||||
int insert_at = clock_at + 1;
|
||||
int tail = (len < NodePrefs::PAGE_ORDER_LEN) ? len : NodePrefs::PAGE_ORDER_LEN - 1;
|
||||
for (int i = tail; i > insert_at; i--) p->page_order[i] = p->page_order[i - 1];
|
||||
p->page_order[insert_at] = NodePrefs::HPB_FAVOURITES + 1;
|
||||
// Guard against a saved order with all PAGE_ORDER_LEN slots already
|
||||
// valid and CLOCK in the last one: insert_at would be PAGE_ORDER_LEN,
|
||||
// one past the array, and the shift loop below wouldn't run (tail is
|
||||
// clamped to the last index) to catch it -- the write would land one
|
||||
// byte past page_order, into whatever NodePrefs field follows.
|
||||
if (insert_at < NodePrefs::PAGE_ORDER_LEN) {
|
||||
int tail = (len < NodePrefs::PAGE_ORDER_LEN) ? len : NodePrefs::PAGE_ORDER_LEN - 1;
|
||||
for (int i = tail; i > insert_at; i--) p->page_order[i] = p->page_order[i - 1];
|
||||
p->page_order[insert_at] = NodePrefs::HPB_FAVOURITES + 1;
|
||||
}
|
||||
}
|
||||
// Append any pages that are absent from the saved order (e.g. added by a
|
||||
// later firmware version). Recount first since the block above may have
|
||||
|
||||
@@ -1779,11 +1779,32 @@ void UITask::addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, u
|
||||
|
||||
int UITask::getDMUnreadTotal() const {
|
||||
int total = 0;
|
||||
for (int i = 0; i < DM_UNREAD_TABLE_SIZE; i++)
|
||||
total += _dm_unread_table[i].count;
|
||||
for (int i = 0; i < DM_UNREAD_TABLE_SIZE; i++) {
|
||||
if (_dm_unread_table[i].count == 0) continue;
|
||||
int held = ((MessagesScreen*)messages_screen)->dmHistCountForContact(_dm_unread_table[i].prefix);
|
||||
total += (_dm_unread_table[i].count < held) ? _dm_unread_table[i].count : held;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
uint8_t UITask::getDMUnread(const uint8_t* pub_key) const {
|
||||
for (int i = 0; i < DM_UNREAD_TABLE_SIZE; i++) {
|
||||
if (_dm_unread_table[i].count > 0 && memcmp(_dm_unread_table[i].prefix, pub_key, 4) == 0) {
|
||||
int held = ((MessagesScreen*)messages_screen)->dmHistCountForContact(pub_key);
|
||||
return _dm_unread_table[i].count < held ? _dm_unread_table[i].count : (uint8_t)held;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
void UITask::reconcileDMUnread() {
|
||||
for (int i = 0; i < DM_UNREAD_TABLE_SIZE; i++) {
|
||||
if (_dm_unread_table[i].count == 0) continue;
|
||||
if (((MessagesScreen*)messages_screen)->dmHistCountForContact(_dm_unread_table[i].prefix) == 0)
|
||||
_dm_unread_table[i].count = 0; // ring no longer holds anything for this sender -- free the slot
|
||||
}
|
||||
}
|
||||
|
||||
void UITask::showAlert(const char* text, int duration_millis) {
|
||||
snprintf(_alert, sizeof(_alert), "%s", text);
|
||||
_alert_expiry = millis() + duration_millis;
|
||||
@@ -2176,6 +2197,7 @@ void UITask::loop() {
|
||||
// Background delivery: resend pending on-device DMs whose ACK timed out, and
|
||||
// finalise the ✗ marker — runs regardless of which screen is active.
|
||||
((MessagesScreen*)messages_screen)->tickDmResends();
|
||||
reconcileDMUnread();
|
||||
#if UI_HAS_JOYSTICK
|
||||
uint8_t joy_rot = _node_prefs ? _node_prefs->joystick_rotation : JOYSTICK_ROTATION;
|
||||
int ev = user_btn.check();
|
||||
@@ -2250,7 +2272,7 @@ void UITask::loop() {
|
||||
} else if (ev == BUTTON_EVENT_DOUBLE_CLICK) {
|
||||
enqueueKey(handleDoubleClick(KEY_PREV));
|
||||
} else if (ev == BUTTON_EVENT_TRIPLE_CLICK) {
|
||||
enqueueKey(handleTripleClick(KEY_SELECT));
|
||||
if (!_locked) enqueueKey(handleTripleClick(KEY_SELECT));
|
||||
}
|
||||
#endif
|
||||
#if defined(PIN_USER_BTN_ANA)
|
||||
@@ -2263,14 +2285,14 @@ void UITask::loop() {
|
||||
} else if (ev == BUTTON_EVENT_DOUBLE_CLICK) {
|
||||
enqueueKey(handleDoubleClick(KEY_PREV));
|
||||
} else if (ev == BUTTON_EVENT_TRIPLE_CLICK) {
|
||||
enqueueKey(handleTripleClick(KEY_SELECT));
|
||||
if (!_locked) enqueueKey(handleTripleClick(KEY_SELECT));
|
||||
}
|
||||
_analogue_pin_read_millis = millis();
|
||||
}
|
||||
#endif
|
||||
pollCardKB();
|
||||
#if defined(BACKLIGHT_BTN)
|
||||
if (millis() > next_backlight_btn_check) {
|
||||
if ((int32_t)(millis() - next_backlight_btn_check) >= 0) {
|
||||
bool touch_state = digitalRead(PIN_BUTTON2);
|
||||
#if defined(DISP_BACKLIGHT)
|
||||
digitalWrite(DISP_BACKLIGHT, !touch_state);
|
||||
@@ -2330,7 +2352,7 @@ void UITask::loop() {
|
||||
tickClockTools();
|
||||
|
||||
if (_display != NULL && _display->isOn()) {
|
||||
if (_locked && millis() > _lock_wake_until) {
|
||||
if (_locked && (int32_t)(millis() - _lock_wake_until) >= 0) {
|
||||
_display->turnOff();
|
||||
} else if (_locked && millis() >= _next_refresh) {
|
||||
_display->startFrame();
|
||||
@@ -2442,7 +2464,7 @@ void UITask::loop() {
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||||
}
|
||||
#endif
|
||||
if (!_locked && autoOffMillis() > 0 && millis() > _auto_off && !isRinging()) {
|
||||
if (!_locked && autoOffMillis() > 0 && (int32_t)(millis() - _auto_off) >= 0 && !isRinging()) {
|
||||
_display->turnOff();
|
||||
#ifdef PIN_LED
|
||||
digitalWrite(PIN_LED, LOW); // turn off status LED with display to save power
|
||||
@@ -2459,7 +2481,7 @@ void UITask::loop() {
|
||||
vibration.loop();
|
||||
#endif
|
||||
|
||||
if (millis() > next_batt_chck) {
|
||||
if ((int32_t)(millis() - next_batt_chck) >= 0) {
|
||||
uint16_t raw = AbstractUITask::getBattMilliVolts();
|
||||
if (raw > 0) {
|
||||
// EMA filter: alpha=0.2 (80% old, 20% new) — smooths ADC noise from uneven load
|
||||
@@ -2706,12 +2728,17 @@ void UITask::clearTargetIfWaypoint(int32_t lat_1e6, int32_t lon_1e6) {
|
||||
// cleared here, so a removed contact can't leave a dangling reference. If you
|
||||
// add such a field, add its cleanup below (and mark the field in NodePrefs.h).
|
||||
// Currently covered: favourite_contacts, locator_key, loc_share_dm_prefix,
|
||||
// dm_notif[], dm_melody[]. Called for both explicit removal and silent
|
||||
// auto-eviction (see MyMesh CMD_REMOVE_CONTACT / onContactOverwrite).
|
||||
// dm_notif[], dm_melody[]. Also clears _dm_unread_table (RAM-only, not a
|
||||
// NodePrefs field, so no savePrefs() needed for it) -- same 4-byte-prefix
|
||||
// shape and same 16-slot starvation risk as dm_notif/dm_melody above. Called
|
||||
// for both explicit removal and silent auto-eviction (see MyMesh
|
||||
// CMD_REMOVE_CONTACT / onContactOverwrite).
|
||||
void UITask::onContactRemoved(const uint8_t* pub_key) {
|
||||
if (!_node_prefs || !pub_key) return;
|
||||
bool changed = false;
|
||||
|
||||
clearDMUnread(pub_key);
|
||||
|
||||
int slot = findFavouriteSlot(pub_key);
|
||||
if (slot >= 0) { clearFavouriteSlot(slot); changed = true; }
|
||||
|
||||
@@ -2977,6 +3004,13 @@ char UITask::checkDisplayOn(char c) {
|
||||
}
|
||||
|
||||
char UITask::handleLongPress(char c) {
|
||||
// Same checkDisplayOn() gate every other input path goes through (see
|
||||
// pollCardKB()'s Fn+letter handling for the same shape) -- without it, a long
|
||||
// press while the display is off neither wakes it nor extends auto-off, and
|
||||
// while unlocked it delivers KEY_CONTEXT_MENU to the invisible screen (found
|
||||
// already open at the next wake instead of the press being consumed as a wake).
|
||||
c = checkDisplayOn(c);
|
||||
if (c == 0) return 0;
|
||||
if (millis() - ui_started_at < 8000) { // long press in first 8 seconds since startup -> CLI/rescue
|
||||
the_mesh.enterCLIRescue();
|
||||
return 0;
|
||||
|
||||
@@ -363,18 +363,20 @@ public:
|
||||
int getChannelUnreadCount() const;
|
||||
int getRoomUnreadCount() const { return _room_unread; }
|
||||
void clearRoomUnread() { _room_unread = 0; }
|
||||
uint8_t getDMUnread(const uint8_t* pub_key) const {
|
||||
for (int i = 0; i < DM_UNREAD_TABLE_SIZE; i++)
|
||||
if (_dm_unread_table[i].count > 0 && memcmp(_dm_unread_table[i].prefix, pub_key, 4) == 0)
|
||||
return _dm_unread_table[i].count;
|
||||
return 0;
|
||||
}
|
||||
// Clamped to the DM ring's actual occupancy for this contact -- defined in
|
||||
// UITask.cpp (needs MessagesScreen to be a complete type). Same self-healing
|
||||
// shape as MessageHistory::chUnread() for channels.
|
||||
uint8_t getDMUnread(const uint8_t* pub_key) const;
|
||||
void clearDMUnread(const uint8_t* pub_key) {
|
||||
for (int i = 0; i < DM_UNREAD_TABLE_SIZE; i++)
|
||||
if (_dm_unread_table[i].count > 0 && memcmp(_dm_unread_table[i].prefix, pub_key, 4) == 0)
|
||||
{ _dm_unread_table[i].count = 0; return; }
|
||||
}
|
||||
void clearAllDMUnread() { memset(_dm_unread_table, 0, sizeof(_dm_unread_table)); }
|
||||
// Frees any table slot whose ring occupancy has dropped to zero (evicted or
|
||||
// deduped-away messages) so a genuinely new sender isn't starved once the
|
||||
// fixed 16-slot table fills with stale entries. Called once per loop().
|
||||
void reconcileDMUnread();
|
||||
bool hasDisplay() const { return _display != NULL; }
|
||||
DisplayDriver* getDisplay() const { return _display; }
|
||||
|
||||
|
||||
@@ -14,8 +14,12 @@
|
||||
|
||||
- **DM/room replies sometimes didn't show who they were addressed to, and room messages could wrap incorrectly.** The reply-prefix (`@[nick] `) was being stripped at the wrong point for room posts and DMs feeding the fullscreen message view, so a DM reply's "To:" header could go missing while a room reply showed the raw `@[nick] ` marker as literal message text; a related mismatch meant the history list's scrollbar/box-height sizing pass wrapped room messages with their sender name still attached, disagreeing with the actual rendered text.
|
||||
- **Remote Bot Actions (`!buzz`/`!gps`/`!advert`/`!gpio1`..`!gpio4`) ran immediately on every matching message, before quiet hours/cooldown/per-contact throttle were checked** — those gates only suppressed the reply text, not the actual buzz/GPS toggle/advert/pin write, so e.g. `!buzz` still audibly buzzed during configured quiet hours, and repeating a command wasn't rate-limited at all. Actions now only run once those checks have passed.
|
||||
- **A channel's unread badge could claim messages the history no longer held** — e.g. showing "7 unread" on a channel that opens to an empty list, and opening it only knocked the count down by one instead of clearing it. The badge is now capped at what the message ring actually still holds, and evicting an old message from a full ring only decrements the counter when the message being dropped was itself unread (previously it decremented regardless, undercounting newer unread messages).
|
||||
- **A channel's or DM/room's unread badge could claim messages the history no longer held** — e.g. showing "7 unread" on a conversation that opens to an empty list, and opening it only knocked the count down by one instead of clearing it. Every unread badge is now capped at what its message ring actually still holds; a channel's counter only decrements on eviction when the dropped message was itself unread (previously it decremented regardless, undercounting newer unread messages), and a DM sender's badge now self-heals (and frees up for a new sender) once their messages have left the shared 32-slot DM ring.
|
||||
- **CardKB's Fn+`<letter>` accent-popup shortcut could open the accent popup while the device was locked**, unlike every other key on the keyboard (all of which are correctly discarded while locked) — it went straight to the keyboard widget instead of through the normal locked-input gate. Now respects the lock like everything else. Since a locked device now correctly ignores CardKB entirely, **Fn+Esc** (single press) is added as CardKB's own lock/unlock gesture — otherwise a CardKB-only setup had no way to unlock at all.
|
||||
- **Triple-click could still toggle the buzzer while the screen was locked**, on boards that don't have a joystick (the single-user-button and analog-button variants) — the joystick variant already correctly suppressed it. Now consistent everywhere.
|
||||
- **Shift didn't capitalise ł, ń, ź, ż (or ĺ, ľ, ň, ž)** — a case-pairing rule for the wider Latin Extended-A block missed a parity flip around three unpaired codepoints, so e.g. typing "Łódź" with Shift held produced "łódź" instead. Fixed for the whole block.
|
||||
- **A long press on the physical button neither woke a sleeping display nor kept it awake**, and — while unlocked with the display off — could deliver its action to a screen you couldn't see, so the context menu was found already open at the next wake instead of the press simply waking the device. Long-press now goes through the same wake/keep-awake handling every other key already gets.
|
||||
- **Typing a character on CardKB right after cycling a T9 letter, then tapping that same T9 cell again quickly, could overwrite the character you'd just typed.** CardKB typing wasn't invalidating the pending T9 cycle the way every on-screen keypress already does.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user