diff --git a/lib/irc/src/irc_session.cpp b/lib/irc/src/irc_session.cpp index 9d320c0..b74f5c7 100644 --- a/lib/irc/src/irc_session.cpp +++ b/lib/irc/src/irc_session.cpp @@ -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(); diff --git a/lib/irc/src/irc_session.h b/lib/irc/src/irc_session.h index 9c76311..7d2aba6 100644 --- a/lib/irc/src/irc_session.h +++ b/lib/irc/src/irc_session.h @@ -68,6 +68,8 @@ class IrcSession { int bufferCount() const { return static_cast(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 namesRequested_; std::vector rejoin_; // IRC channels to join again after a reconnect IrcEffects effects_; + uint32_t revision_ = 0; }; } // namespace roro diff --git a/src/apps/irc_app.cpp b/src/apps/irc_app.cpp new file mode 100644 index 0000000..7bee493 --- /dev/null +++ b/src/apps/irc_app.cpp @@ -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(std::tolower(static_cast(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(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> 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(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 diff --git a/src/apps/irc_app.h b/src/apps/irc_app.h new file mode 100644 index 0000000..d711529 --- /dev/null +++ b/src/apps/irc_app.h @@ -0,0 +1,55 @@ +#pragma once + +#include + +#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 diff --git a/src/main.cpp b/src/main.cpp index 442d45a..d49b617 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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)}); diff --git a/src/services/irc_service.cpp b/src/services/irc_service.cpp index 4ecd3a6..44c9da8 100644 --- a/src/services/irc_service.cpp +++ b/src/services/irc_service.cpp @@ -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(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; diff --git a/src/services/irc_service.h b/src/services/irc_service.h index fd720ac..a65a224 100644 --- a/src/services/irc_service.h +++ b/src/services/irc_service.h @@ -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; diff --git a/test/test_irc_session/test_irc_session.cpp b/test/test_irc_session/test_irc_session.cpp index ec18262..fcdbd7b 100644 --- a/test/test_irc_session/test_irc_session.cpp +++ b/test/test_irc_session/test_irc_session.cpp @@ -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(); }