Public Access
IrcService::disconnect() stops the session from any state: QUIT if connected, then no more retries. /quit goes through it (before, it only stopped a connected session; while waiting for Wi-Fi or retrying it did nothing), and so does the new `irc stop` command. Stopped by hand, opening the IRC App no longer reconnects; typing a line does. mDNS is gone: it never crossed the dev box's routed network, and it cost about 7.5 KB of RAM. Pushes go to the IP shown in Settings -> Firmware, which drops its Name row. OTA Q54 records the change. On the device, Debug Build: 105.6 KB free with Wi-Fi (was 98); with IRC on TLS 54 KB free (was 46); after `irc stop`, back to 99 KB. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
318 lines
12 KiB
C++
318 lines
12 KiB
C++
#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;
|
|
if (!irc_.stoppedByUser()) irc_.connect(); // stopped by hand: stay stopped, typing reconnects
|
|
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) {
|
|
// Alt + ; / Alt + . scroll the Buffer; Up / Down (Fn + ; / Fn + .) recall sent lines.
|
|
if (e.key == Key::Char && e.alt && (e.ch == ';' || e.ch == '.')) {
|
|
if (e.ch == ';') scroll_++; // older
|
|
else if (scroll_ > 0) scroll_--;
|
|
return true;
|
|
}
|
|
std::string recalled;
|
|
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:
|
|
if (history_.up(input_.text(), recalled)) input_.setText(recalled);
|
|
break;
|
|
case Key::Down:
|
|
if (history_.down(recalled)) input_.setText(recalled);
|
|
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;
|
|
history_.add(text);
|
|
input_.setText("");
|
|
scroll_ = 0;
|
|
if (lowered(text) == "/settings") {
|
|
draft_.reset(new IrcConfig(irc_.draftConfig()));
|
|
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 = *draft_;
|
|
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 = *draft_;
|
|
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 = *draft_;
|
|
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 = *draft_;
|
|
switch (e.key) {
|
|
case Key::Up: fields_.up(); break;
|
|
case Key::Down: fields_.down(); break;
|
|
case Key::Back:
|
|
draft_.reset(); // 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_.applyConfig(c);
|
|
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, #private key" : "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
|