#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; 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(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> 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, #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