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:
@@ -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
|
||||
Reference in New Issue
Block a user