feat(channels): Channel order setting - slots, newest message or name (#48)

Shared chanctl::order() builds the channel list for ui-new and the L2:
favourites first, then by ch_sort. New ch_sort pref at the end of NodePrefs,
Settings > Contacts row and contacts.channel.order CLI key.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-10-14 10:46:20 +02:00
co-authored by Claude Sonnet 5.5
parent 140ea7c008
commit 24dd025086
6 changed files with 75 additions and 29 deletions
+5
View File
@@ -535,6 +535,11 @@ struct NodePrefs { // persisted to file
// ui-new: 0 = big clock + date + unread pill, 1 = compact clock with the
// clock fields as rows below. The L2 has its own lock screen.
uint8_t lock_compact;
// Settings > Contacts > "Channel order": how the channel lists are sorted
// (after favourites, when those go first): 0 = as the slots are (default),
// 1 = latest message first, 2 = by name. See chanctl::order().
static const uint8_t CH_SORT_COUNT = 3;
uint8_t ch_sort;
// Single source of truth for the live-share option tables (shared by the Map
// UI labels and the auto-send engine in UITask).
@@ -198,6 +198,62 @@ static void setMelody(NodePrefs* p, uint8_t idx, uint8_t slot) {
static bool favourite(const NodePrefs* p, uint8_t idx) { return p && (p->ch_fav_bitmask & (1ULL << idx)); }
// The channels a list shows, in order: out[] gets the slot numbers, the count
// comes back. Favourites first (fav_sort), then by ch_sort -- slot order, the
// channel with the newest message first (channels without one after those,
// in slot order), or by name. Stable, so equal rows keep their slot order.
static int order(const NodePrefs* p, const MessageHistory& hist, uint8_t* out, bool fav_only) {
int n = 0;
for (int i = 0; i < MAX_GROUP_CHANNELS; i++) {
ChannelDetails ch;
if (!the_mesh.getChannel(i, ch) || ch.name[0] == '\0') continue;
if (fav_only && !favourite(p, i)) continue;
out[n++] = (uint8_t)i;
}
const bool fav_first = !p || p->fav_sort;
const uint8_t mode = p && p->ch_sort < NodePrefs::CH_SORT_COUNT ? p->ch_sort : 0;
if (n < 2 || (!fav_first && mode == 0)) return n;
uint16_t rank[MAX_GROUP_CHANNELS]; // newest message = 0
for (int i = 0; i < MAX_GROUP_CHANNELS; i++) rank[i] = 0xFFFF;
if (mode == 1) {
int left = n;
for (int j = 0; j < hist.chHistCount() && left > 0; j++) {
uint8_t c = hist.chAtPos(hist.chHistPosNewest(j)).ch_idx;
if (c < MAX_GROUP_CHANNELS && rank[c] == 0xFFFF) { rank[c] = (uint16_t)j; left--; }
}
}
// true when a goes before b
auto before = [&](uint8_t a, uint8_t b) {
if (fav_first) {
bool fa = favourite(p, a), fb = favourite(p, b);
if (fa != fb) return fa;
}
if (mode == 1) return rank[a] < rank[b];
if (mode == 2) {
ChannelDetails ca, cb;
the_mesh.getChannel(a, ca);
the_mesh.getChannel(b, cb);
const char* x = bareName(ca.name);
const char* y = bareName(cb.name);
for (;; x++, y++) {
char u = *x >= 'A' && *x <= 'Z' ? *x + 32 : *x;
char v = *y >= 'A' && *y <= 'Z' ? *y + 32 : *y;
if (u != v) return (uint8_t)u < (uint8_t)v;
if (!u) return false;
}
}
return false;
};
for (int i = 1; i < n; i++) { // insertion sort: stable
uint8_t v = out[i];
int j = i;
while (j > 0 && before(v, out[j - 1])) { out[j] = out[j - 1]; j--; }
out[j] = v;
}
return n;
}
static void setFavourite(NodePrefs* p, uint8_t idx, bool on) {
if (!p) return;
if (on) p->ch_fav_bitmask |= 1ULL << idx; else p->ch_fav_bitmask &= ~(1ULL << idx);
@@ -21,7 +21,7 @@ static const Key KEYS[] = {
K("autoadd", manual_add_contacts), KB("autoadd.chat", autoadd_config, 0x02), KB("autoadd.repeater", autoadd_config, 0x04),
KB("autoadd.room", autoadd_config, 0x08), KB("autoadd.sensor", autoadd_config, 0x10),
KB("autoadd.overwrite", autoadd_config, 0x01), K("autoadd.max.hops", autoadd_max_hops),
K("contacts.expiry", contact_expiry_idx), K("contacts.fav.first", fav_sort), K("dm.resend", dm_resend_count),
K("contacts.expiry", contact_expiry_idx), K("contacts.fav.first", fav_sort), K("contacts.channel.order", ch_sort), K("dm.resend", dm_resend_count),
K("display.brightness", display_brightness), K("display.off", auto_off_secs), K("display.wake", msg_wake),
K("display.alert", msg_alert), K("lock", auto_lock), K("lock.clock", lock_compact),
K("display.text.size", ui_text_scale), K("display.icon.size", ui_icon_scale),
@@ -157,6 +157,10 @@ static void optLockLook(uint8_t v, char* b, int n, const NodePrefs&) { snprintf(
static void optExpiry(uint8_t v, char* b, int n, const NodePrefs&) {
snprintf(b, n, "%s", NodePrefs::contactExpiryLabel(v));
}
static void optChSort(uint8_t v, char* b, int n, const NodePrefs&) {
static const char* L[NodePrefs::CH_SORT_COUNT] = { "Slots", "Newest", "Name" };
snprintf(b, n, "%s", L[v < NodePrefs::CH_SORT_COUNT ? v : 0]);
}
static void optVolume(uint8_t v, char* b, int n, const NodePrefs&) {
static const char* L[5] = { "Quietest", "Quiet", "Medium", "Loud", "Loudest" };
snprintf(b, n, "%s", L[v < 5 ? v : 4]);
@@ -281,6 +285,8 @@ static const Setting ALL[] = {
IDX("Contact expiry", "Prune after inactivity", SEC_CONTACTS, contact_expiry_idx,
NodePrefs::CONTACT_EXPIRY_COUNT, optExpiry, nullptr),
SW("Favourites first", "In contact and node lists", SEC_CONTACTS, fav_sort, nullptr),
IDX("Channel order", "Slots, newest message or name", SEC_CONTACTS, ch_sort,
NodePrefs::CH_SORT_COUNT, optChSort, nullptr),
// manual_add_contacts bit 0: 0 adds every node heard, 1 only the types below.
IDX("Add heard nodes", "Every node, or only these types", SEC_AUTO_ADD, manual_add_contacts, 2, optAutoAdd, nullptr),
@@ -333,7 +339,8 @@ static const struct { uint16_t offset; const char* text; } SHORT_LABELS[] = {
{ NP_OFF(gps_interval), "GPS pwr" }, { NP_OFF(clock_12h), "12h clock" },
{ NP_OFF(clock_hide_seconds), "Seconds" }, { NP_OFF(units_imperial), "Imperial" },
{ NP_OFF(dm_resend_count), "Resend" }, { NP_OFF(contact_expiry_idx), "Expire" },
{ NP_OFF(fav_sort), "Favs top" }, { NP_OFF(buzzer_volume), "Buzzer vol" },
{ NP_OFF(fav_sort), "Favs top" }, { NP_OFF(ch_sort), "Ch order" },
{ NP_OFF(buzzer_volume), "Buzzer vol" },
{ NP_OFF(quiet_hours), "Quiet hrs" }, { NP_OFF(quiet_from), " from" },
{ NP_OFF(quiet_to), " until" }, { NP_OFF(notif_melody_dm), "DM sound" },
{ NP_OFF(notif_melody_ch), "Ch sound" }, { NP_OFF(notif_melody_ad), "AD sound" },
+4 -10
View File
@@ -224,16 +224,10 @@ void UITask::fillChats() {
own();
bool fav_first = !_prefs || _prefs->fav_sort;
int ch_rows = 0;
for (int pass = fav_first ? 0 : 1; pass < 2 && !chatFolded(FOLD_CHANNELS); pass++) {
for (int i = 0; i < MAX_GROUP_CHANNELS; i++) {
ChannelDetails ch;
if (!the_mesh.getChannel(i, ch) || ch.name[0] == '\0') continue;
bool fav = chanctl::favourite(_prefs, i);
if (ch_fav_only && !fav) continue;
if (fav_first && fav != (pass == 0)) continue;
ch_rows++;
vlist::add(body, &CHANNEL, i);
}
if (!chatFolded(FOLD_CHANNELS)) {
uint8_t order[MAX_GROUP_CHANNELS];
ch_rows = chanctl::order(_prefs, _core->history, order, ch_fav_only);
for (int r = 0; r < ch_rows; r++) vlist::add(body, &CHANNEL, order[r]);
}
if (!chatFolded(FOLD_CHANNELS)) {
if (ch_rows == 0 && ch_fav_only) { label(body, "No favourite channels", THEME_FONT_SMALL, theme::TEXT_MUTED); own(); }
@@ -836,23 +836,7 @@ class MessagesScreen : public UIScreen {
void buildChannelList() {
NodePrefs* p = _task->getNodePrefs();
bool fav_only = (p && p->ch_fav_only);
_num_channels = 0;
for (int i = 0; i < MAX_GROUP_CHANNELS; i++) {
ChannelDetails ch;
if (!the_mesh.getChannel(i, ch) || ch.name[0] == '\0') continue;
if (fav_only && !(p->ch_fav_bitmask & (1ULL << i))) continue;
_channel_indices[_num_channels++] = (uint8_t)i;
}
// Favourites to the front, everything else keeping channel-slot order.
if (!p || !p->fav_sort) return;
int front = 0;
for (int i = 0; i < _num_channels; i++) {
if (!chIsFav(_channel_indices[i])) continue;
uint8_t v = _channel_indices[i];
for (int j = i; j > front; j--) _channel_indices[j] = _channel_indices[j - 1];
_channel_indices[front++] = v;
}
_num_channels = chanctl::order(p, _history, _channel_indices, p && p->ch_fav_only);
}
// Advance one of the contact menu's value rows. dir is +1 for RIGHT and for