Public Access
M1 step 5: IRC App
- Chat: header (Buffer n/N, unread elsewhere, topic or connection state), wrapped hh:mm <nick> lines (own in accent, Mentions green, info grey), input line; Tab cycles Buffers, Fn+Up/Down scrolls back, Enter sends, Back leaves while the IRC Service keeps running - /settings: server form (host, port, TLS, self-signed pinning, nick, SASL, NickServ, auto-join); Save & reconnect restarts the session - Opening the App starts the IRC Service; viewing a Buffer clears its unread count; IrcSession gains a revision counter for redraws Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
@@ -62,11 +62,13 @@ int IrcSession::bufferFor(const std::string& name, IrcBuffer::Type type) {
|
||||
if (i >= 0) return i;
|
||||
if (bufferCount() >= kMaxBuffers) return 0; // full: fall back to the server Buffer
|
||||
buffers_.push_back({name, type, {}, 0, false, false, ""});
|
||||
revision_++;
|
||||
return bufferCount() - 1;
|
||||
}
|
||||
|
||||
void IrcSession::setViewing(int buffer) {
|
||||
viewing_ = buffer;
|
||||
revision_++;
|
||||
if (buffer >= 0 && buffer < bufferCount()) {
|
||||
buffers_[buffer].unread = 0;
|
||||
buffers_[buffer].mentioned = false;
|
||||
@@ -87,6 +89,7 @@ bool IrcSession::mentionsMe(const std::string& text) const {
|
||||
void IrcSession::add(int b, IrcLine::Kind kind, const std::string& nick, const std::string& text, int64_t utc,
|
||||
bool mention) {
|
||||
IrcBuffer& buf = buffers_[b];
|
||||
revision_++;
|
||||
IrcLine line{utc, kind, nick, text};
|
||||
buf.lines.push_back(line);
|
||||
if (buf.lines.size() > kLinesPerBuffer) buf.lines.pop_front();
|
||||
|
||||
@@ -68,6 +68,8 @@ class IrcSession {
|
||||
int bufferCount() const { return static_cast<int>(buffers_.size()); }
|
||||
const IrcBuffer& buffer(int i) const { return buffers_[i]; }
|
||||
int totalUnread() const;
|
||||
// Changes whenever a Buffer gains a line or changes, so a view knows when to redraw.
|
||||
uint32_t revision() const { return revision_; }
|
||||
|
||||
IrcEffects takeEffects();
|
||||
|
||||
@@ -93,6 +95,7 @@ class IrcSession {
|
||||
std::set<std::string> namesRequested_;
|
||||
std::vector<std::string> rejoin_; // IRC channels to join again after a reconnect
|
||||
IrcEffects effects_;
|
||||
uint32_t revision_ = 0;
|
||||
};
|
||||
|
||||
} // namespace roro
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
#include "irc_app.h"
|
||||
|
||||
#include "ui/fonts.h"
|
||||
#include "ui/widgets.h"
|
||||
|
||||
namespace roro {
|
||||
|
||||
using Status = IrcService::Status;
|
||||
|
||||
namespace {
|
||||
|
||||
const char* statusText(Status s) {
|
||||
switch (s) {
|
||||
case Status::Stopped: return "disconnected";
|
||||
case Status::WaitingForWifi: return "waiting for Wi-Fi";
|
||||
case Status::Connecting: return "connecting...";
|
||||
case Status::Registering: return "logging in...";
|
||||
case Status::Online: return "";
|
||||
case Status::Paused: return "paused (Wi-Fi monitoring)";
|
||||
case Status::Retrying: return "retrying soon";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
uint16_t lineColor(const IrcLine& l, bool mention) {
|
||||
switch (l.kind) {
|
||||
case IrcLine::Kind::Own:
|
||||
case IrcLine::Kind::OwnAction: return theme::kAccent;
|
||||
case IrcLine::Kind::Info:
|
||||
case IrcLine::Kind::Notice: return theme::kMuted;
|
||||
default: return mention ? theme::kMessage : theme::kText;
|
||||
}
|
||||
}
|
||||
|
||||
std::string lowered(std::string s) {
|
||||
for (auto& c : s) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||
return s;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void IrcApp::onEnter() {
|
||||
page_ = Page::Chat;
|
||||
editing_ = false;
|
||||
irc_.connect();
|
||||
view(current_);
|
||||
}
|
||||
|
||||
void IrcApp::onExit() {
|
||||
irc_.withSession([](IrcSession& s) { s.setViewing(-1); });
|
||||
}
|
||||
|
||||
void IrcApp::view(int buffer) {
|
||||
irc_.withSession([&](IrcSession& s) {
|
||||
if (buffer < 0 || buffer >= s.bufferCount()) buffer = 0;
|
||||
current_ = buffer;
|
||||
s.setViewing(buffer);
|
||||
});
|
||||
scroll_ = 0;
|
||||
requestRedraw();
|
||||
}
|
||||
|
||||
void IrcApp::warn(const std::string& text) {
|
||||
bus_.publish(Event::withText(EventType::Notification, text.c_str(), static_cast<int32_t>(NotificationLevel::Warning)));
|
||||
}
|
||||
|
||||
void IrcApp::update(uint32_t) {
|
||||
uint32_t revision = irc_.withSession([](IrcSession& s) { return s.revision(); });
|
||||
Status status = irc_.status();
|
||||
if (revision != seenRevision_ || status != seenStatus_) {
|
||||
seenRevision_ = revision;
|
||||
seenStatus_ = status;
|
||||
requestRedraw();
|
||||
}
|
||||
}
|
||||
|
||||
bool IrcApp::onKey(const KeyEvent& e) {
|
||||
requestRedraw();
|
||||
return page_ == Page::Chat ? onChatKey(e) : onSettingsKey(e);
|
||||
}
|
||||
|
||||
bool IrcApp::onChatKey(const KeyEvent& e) {
|
||||
switch (e.key) {
|
||||
case Key::Char: input_.insert(e.ch); break;
|
||||
case Key::Delete: input_.backspace(); break;
|
||||
case Key::Left: input_.left(); break;
|
||||
case Key::Right: input_.right(); break;
|
||||
case Key::Up: scroll_++; break; // older
|
||||
case Key::Down: if (scroll_ > 0) scroll_--; break;
|
||||
case Key::Tab: {
|
||||
int count = irc_.withSession([](IrcSession& s) { return s.bufferCount(); });
|
||||
view((current_ + 1) % count);
|
||||
break;
|
||||
}
|
||||
case Key::Select: {
|
||||
std::string text = input_.text();
|
||||
if (text.empty()) break;
|
||||
input_.setText("");
|
||||
scroll_ = 0;
|
||||
if (lowered(text) == "/settings") {
|
||||
page_ = Page::Settings;
|
||||
fields_.setCount(kFields);
|
||||
break;
|
||||
}
|
||||
if (!irc_.running()) irc_.connect(); // e.g. typing after /quit reconnects
|
||||
irc_.withSession([&](IrcSession& s) { s.input(current_, text, clock_.utcNow()); });
|
||||
// /msg or /join may have opened a Buffer: follow a new private chat.
|
||||
if (lowered(text).rfind("/msg ", 0) == 0) {
|
||||
std::string who = text.substr(5, text.find(' ', 5) == std::string::npos ? std::string::npos : text.find(' ', 5) - 5);
|
||||
irc_.withSession([&](IrcSession& s) {
|
||||
for (int i = 0; i < s.bufferCount(); i++)
|
||||
if (lowered(s.buffer(i).name) == lowered(who)) current_ = i;
|
||||
s.setViewing(current_);
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: return false; // Back leaves the App; IRC keeps running
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string IrcApp::fieldLabel(int f) const {
|
||||
static const char* labels[] = {"Server", "Port", "TLS", "Self-signed", "Nick", "SASL user", "SASL password",
|
||||
"NickServ password", "Auto-join", "Save & reconnect"};
|
||||
return labels[f];
|
||||
}
|
||||
|
||||
std::string IrcApp::fieldValue(int f) const {
|
||||
const IrcConfig& c = irc_.config();
|
||||
auto secret = [](const std::string& s) { return s.empty() ? std::string("-") : std::string("(set)"); };
|
||||
switch (f) {
|
||||
case kHost: return c.host;
|
||||
case kPort: return std::to_string(c.port);
|
||||
case kTls: return c.tls ? "On" : "Off";
|
||||
case kSelfSigned: return c.allowSelfSigned ? (c.pinnedSha256.empty() ? "Pin on first use" : "Pinned") : "Off";
|
||||
case kNick: return c.nick;
|
||||
case kSaslUser: return c.saslUser.empty() ? "-" : c.saslUser;
|
||||
case kSaslPass: return secret(c.saslPassword);
|
||||
case kNickserv: return secret(c.nickservPassword);
|
||||
case kAutojoin: return c.autojoin.empty() ? "-" : IrcConfig::formatChannels(c.autojoin);
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
|
||||
void IrcApp::startEditing(int f) {
|
||||
const IrcConfig& c = irc_.config();
|
||||
fieldEditor_ = LineEditor(f == kAutojoin ? 200 : 63);
|
||||
switch (f) {
|
||||
case kHost: fieldEditor_.setText(c.host); break;
|
||||
case kPort: fieldEditor_.setText(std::to_string(c.port)); break;
|
||||
case kNick: fieldEditor_.setText(c.nick); break;
|
||||
case kSaslUser: fieldEditor_.setText(c.saslUser); break;
|
||||
case kAutojoin: fieldEditor_.setText(IrcConfig::formatChannels(c.autojoin)); break;
|
||||
default: break; // passwords start empty
|
||||
}
|
||||
editing_ = true;
|
||||
}
|
||||
|
||||
void IrcApp::finishEditing() {
|
||||
IrcConfig& c = irc_.config();
|
||||
const std::string& v = fieldEditor_.text();
|
||||
switch (fields_.selected()) {
|
||||
case kHost: c.host = v; break;
|
||||
case kPort: c.port = std::atoi(v.c_str()); break;
|
||||
case kNick: c.nick = v; break;
|
||||
case kSaslUser: c.saslUser = v; break;
|
||||
case kSaslPass: c.saslPassword = v; break;
|
||||
case kNickserv: c.nickservPassword = v; break;
|
||||
case kAutojoin: c.autojoin = IrcConfig::parseChannels(v); break;
|
||||
}
|
||||
editing_ = false;
|
||||
}
|
||||
|
||||
bool IrcApp::onSettingsKey(const KeyEvent& e) {
|
||||
if (editing_) {
|
||||
switch (e.key) {
|
||||
case Key::Char: fieldEditor_.insert(e.ch); break;
|
||||
case Key::Delete: fieldEditor_.backspace(); break;
|
||||
case Key::Left: fieldEditor_.left(); break;
|
||||
case Key::Right: fieldEditor_.right(); break;
|
||||
case Key::Select: finishEditing(); break;
|
||||
case Key::Back: editing_ = false; break;
|
||||
default: break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
IrcConfig& c = irc_.config();
|
||||
switch (e.key) {
|
||||
case Key::Up: fields_.up(); break;
|
||||
case Key::Down: fields_.down(); break;
|
||||
case Key::Back:
|
||||
c.load(c.nick); // discard unsaved edits
|
||||
page_ = Page::Chat;
|
||||
break;
|
||||
case Key::Select:
|
||||
switch (fields_.selected()) {
|
||||
case kTls: c.tls = !c.tls; break;
|
||||
case kSelfSigned:
|
||||
c.allowSelfSigned = !c.allowSelfSigned;
|
||||
c.pinnedSha256.clear();
|
||||
break;
|
||||
case kSave: {
|
||||
std::string error = irc_.saveConfig();
|
||||
if (!error.empty()) {
|
||||
warn(error);
|
||||
break;
|
||||
}
|
||||
page_ = Page::Chat;
|
||||
view(0);
|
||||
break;
|
||||
}
|
||||
default: startEditing(fields_.selected()); break;
|
||||
}
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void IrcApp::draw(Canvas& c) {
|
||||
if (page_ == Page::Chat) drawChat(c);
|
||||
else drawSettings(c);
|
||||
}
|
||||
|
||||
void IrcApp::drawChat(Canvas& c) {
|
||||
const auto& area = theme::kContent;
|
||||
const int inputH = theme::kLineHeight + 4;
|
||||
const int headerH = 11;
|
||||
const theme::Rect messages{area.x, area.y + headerH, area.w, area.h - headerH - inputH - 1};
|
||||
|
||||
std::vector<std::pair<std::string, uint16_t>> lines; // wrapped, oldest first
|
||||
std::string header;
|
||||
irc_.withSession([&](IrcSession& s) {
|
||||
if (current_ >= s.bufferCount()) current_ = 0;
|
||||
const IrcBuffer& b = s.buffer(current_);
|
||||
header = b.name + " " + std::to_string(current_ + 1) + "/" + std::to_string(s.bufferCount());
|
||||
int others = s.totalUnread() - b.unread;
|
||||
if (others > 0) header += " [" + std::to_string(others) + "]";
|
||||
std::string state = statusText(irc_.status());
|
||||
header += " " + (state.empty() ? b.topic : state);
|
||||
|
||||
auto measure = widgets::bodyMeasure(c);
|
||||
std::string me = lowered(s.nick());
|
||||
for (const IrcLine& l : b.lines) {
|
||||
std::string when = l.utc >= 0 ? ClockModel::formatLocalTime(l.utc) + " " : "";
|
||||
std::string text;
|
||||
switch (l.kind) {
|
||||
case IrcLine::Kind::Action:
|
||||
case IrcLine::Kind::OwnAction: text = "* " + l.nick + " " + l.text; break;
|
||||
case IrcLine::Kind::Notice: text = "-" + l.nick + "- " + l.text; break;
|
||||
case IrcLine::Kind::Info: text = l.text; break;
|
||||
default: text = "<" + l.nick + "> " + l.text; break;
|
||||
}
|
||||
bool mention = l.kind == IrcLine::Kind::Message && lowered(l.text).find(me) != std::string::npos;
|
||||
for (auto& w : wrapText(when + text, messages.w - 8, measure)) lines.push_back({w, lineColor(l, mention)});
|
||||
}
|
||||
});
|
||||
|
||||
// Header
|
||||
c.setFont(&fonts::small);
|
||||
c.setTextColor(theme::kAccent);
|
||||
c.setClipRect(area.x, area.y, area.w, headerH);
|
||||
c.drawString(header.c_str(), 3, area.y + 2);
|
||||
c.clearClipRect();
|
||||
|
||||
// Messages, newest at the bottom, scrolled back by scroll_ lines.
|
||||
int rows = messages.h / theme::kLineHeight;
|
||||
int total = static_cast<int>(lines.size());
|
||||
if (scroll_ > total - rows) scroll_ = total > rows ? total - rows : 0;
|
||||
int first = total - rows - scroll_;
|
||||
if (first < 0) first = 0;
|
||||
c.setFont(&fonts::body);
|
||||
c.setClipRect(messages.x, messages.y, messages.w, messages.h);
|
||||
for (int r = 0; r < rows && first + r < total; r++) {
|
||||
c.setTextColor(lines[first + r].second);
|
||||
c.drawString(lines[first + r].first.c_str(), 4, messages.y + r * theme::kLineHeight + 1);
|
||||
}
|
||||
c.clearClipRect();
|
||||
if (scroll_ > 0) {
|
||||
c.setFont(&fonts::small);
|
||||
c.setTextColor(theme::kWarning);
|
||||
c.drawString(("^ " + std::to_string(scroll_)).c_str(), area.w - 30, messages.y + 1);
|
||||
}
|
||||
|
||||
widgets::lineEditor(c, input_, {2, area.y + area.h - inputH, area.w - 4, 0});
|
||||
}
|
||||
|
||||
void IrcApp::drawSettings(Canvas& c) {
|
||||
const auto& area = theme::kContent;
|
||||
if (editing_) {
|
||||
c.setFont(&fonts::body);
|
||||
c.setTextColor(theme::kMuted);
|
||||
c.drawString(fieldLabel(fields_.selected()).c_str(), 4, area.y + 4);
|
||||
widgets::lineEditor(c, fieldEditor_, {4, area.y + 22, area.w - 8, 0});
|
||||
c.drawString(fields_.selected() == kAutojoin ? "e.g. #roro #meshtastic" : "Enter: OK `: cancel", 4,
|
||||
area.y + 44);
|
||||
return;
|
||||
}
|
||||
widgets::list(
|
||||
c, fields_, area, [this](int i) { return fieldLabel(i); }, [this](int i) { return fieldValue(i); });
|
||||
}
|
||||
|
||||
} // namespace roro
|
||||
@@ -0,0 +1,55 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "app.h"
|
||||
#include "event_bus.h"
|
||||
#include "line_editor.h"
|
||||
#include "list_model.h"
|
||||
#include "services/irc_service.h"
|
||||
#include "ui/theme.h"
|
||||
|
||||
namespace roro {
|
||||
|
||||
// IRC: one Buffer at a time with an input line; /settings opens the server form. Opening the App
|
||||
// starts the IRC Service, which keeps running after the App is left.
|
||||
class IrcApp : public App {
|
||||
public:
|
||||
IrcApp(IrcService& irc, ClockService& clock, EventBus& bus) : irc_(irc), clock_(clock), bus_(bus) {}
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
bool onKey(const KeyEvent& e) override;
|
||||
void update(uint32_t nowMs) override;
|
||||
bool textEntryActive() const override { return page_ == Page::Chat || editing_; }
|
||||
void draw(Canvas& c) override;
|
||||
|
||||
private:
|
||||
enum class Page { Chat, Settings };
|
||||
enum Field { kHost, kPort, kTls, kSelfSigned, kNick, kSaslUser, kSaslPass, kNickserv, kAutojoin, kSave, kFields };
|
||||
|
||||
bool onChatKey(const KeyEvent& e);
|
||||
bool onSettingsKey(const KeyEvent& e);
|
||||
void view(int buffer);
|
||||
void drawChat(Canvas& c);
|
||||
void drawSettings(Canvas& c);
|
||||
std::string fieldLabel(int f) const;
|
||||
std::string fieldValue(int f) const;
|
||||
void startEditing(int f);
|
||||
void finishEditing();
|
||||
void warn(const std::string& text);
|
||||
|
||||
IrcService& irc_;
|
||||
ClockService& clock_;
|
||||
EventBus& bus_;
|
||||
Page page_ = Page::Chat;
|
||||
int current_ = 0;
|
||||
int scroll_ = 0; // wrapped lines scrolled back from the bottom
|
||||
uint32_t seenRevision_ = 0;
|
||||
IrcService::Status seenStatus_ = IrcService::Status::Stopped;
|
||||
LineEditor input_{400};
|
||||
ListModel fields_{theme::kContent.h / theme::kLineHeight};
|
||||
bool editing_ = false;
|
||||
LineEditor fieldEditor_{63};
|
||||
};
|
||||
|
||||
} // namespace roro
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
#include "app_manager.h"
|
||||
#include "apps/demo_app.h"
|
||||
#include "apps/irc_app.h"
|
||||
#include "apps/launcher_app.h"
|
||||
#include "apps/settings_app.h"
|
||||
#include "apps/setup_app.h"
|
||||
@@ -113,6 +114,7 @@ void setup() {
|
||||
|
||||
apps = new AppManager(launcher);
|
||||
launcher.setManager(*apps);
|
||||
apps->registerApp({"irc", "IRC", false, new IrcApp(*irc, *clockService, bus)});
|
||||
apps->registerApp({"settings", "Settings", false,
|
||||
new SettingsApp({settings, bus, *apps, *battery, *storageService, *clockService, *wifi, *savedNetworks})});
|
||||
apps->registerApp({"demo", "Widget demo", true, new DemoApp(bus)});
|
||||
|
||||
@@ -50,11 +50,14 @@ int IrcService::totalUnread() {
|
||||
|
||||
std::string IrcService::saveConfig() {
|
||||
std::string error = config_.save();
|
||||
if (error.empty() && !wanted_) {
|
||||
if (!error.empty()) return error;
|
||||
if (wanted_) {
|
||||
restart_ = true; // the task reconnects with a fresh session
|
||||
} else {
|
||||
Lock l(lock_);
|
||||
session_.reset(new IrcSession(config_)); // a new server or nick starts a fresh session
|
||||
}
|
||||
return error;
|
||||
return "";
|
||||
}
|
||||
|
||||
void IrcService::taskEntry(void* self) { static_cast<IrcService*>(self)->loop(); }
|
||||
@@ -178,6 +181,18 @@ void IrcService::loop() {
|
||||
bool wifiUp = wifi_.state() == WifiController::State::Connected;
|
||||
bool monitoring = wifi_.state() == WifiController::State::Monitoring;
|
||||
|
||||
if (restart_) {
|
||||
restart_ = false;
|
||||
if (open_) {
|
||||
conn_->print("QUIT :reconfiguring\r\n");
|
||||
vTaskDelay(pdMS_TO_TICKS(300));
|
||||
close("");
|
||||
}
|
||||
Lock l(lock_);
|
||||
session_.reset(new IrcSession(config_));
|
||||
retryAtMs_ = now;
|
||||
backoff_.reset();
|
||||
}
|
||||
if (!wanted_) {
|
||||
if (open_) close("disconnected");
|
||||
status_ = Status::Stopped;
|
||||
|
||||
@@ -45,7 +45,7 @@ class IrcService : public Service {
|
||||
int totalUnread();
|
||||
|
||||
IrcConfig& config() { return config_; }
|
||||
// Saves the config; takes effect at the next connection.
|
||||
// Saves the config and, if running, reconnects with it (a fresh session).
|
||||
std::string saveConfig();
|
||||
|
||||
private:
|
||||
@@ -77,6 +77,7 @@ class IrcService : public Service {
|
||||
ReconnectPolicy backoff_;
|
||||
std::string partial_;
|
||||
volatile bool wanted_ = false;
|
||||
volatile bool restart_ = false;
|
||||
volatile Status status_ = Status::Stopped;
|
||||
bool open_ = false;
|
||||
uint32_t retryAtMs_ = 0;
|
||||
|
||||
@@ -333,6 +333,16 @@ void test_server_buffer_never_counts_as_unread() {
|
||||
TEST_ASSERT_EQUAL(0, f.session->totalUnread());
|
||||
}
|
||||
|
||||
void test_revision_changes_when_something_visible_changes() {
|
||||
Fixture f;
|
||||
f.registerNow();
|
||||
uint32_t r = f.session->revision();
|
||||
f.recv("PING :x"); // nothing visible
|
||||
TEST_ASSERT_EQUAL_UINT32(r, f.session->revision());
|
||||
f.recv(":roro!u@h JOIN #roro");
|
||||
TEST_ASSERT_TRUE(f.session->revision() != r);
|
||||
}
|
||||
|
||||
int main() {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_plain_registration_sends_nick_and_user);
|
||||
@@ -360,5 +370,6 @@ int main() {
|
||||
RUN_TEST(test_buffer_keeps_only_recent_lines);
|
||||
RUN_TEST(test_quit_is_forgotten_once_disconnected);
|
||||
RUN_TEST(test_server_buffer_never_counts_as_unread);
|
||||
RUN_TEST(test_revision_changes_when_something_visible_changes);
|
||||
return UNITY_END();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user