-- IRC client plugin for KOReader -- Implements a simple IRC client using LuaSocket. -- Provides a submenu with Server list & Username, and a basic chat view. local DataStorage = require("datastorage") local Dispatcher = require("dispatcher") local InfoMessage = require("ui/widget/infomessage") local InputDialog = require("ui/widget/inputdialog") local LuaSettings = require("luasettings") local MultiInputDialog = require("ui/widget/multiinputdialog") local ConfirmBox = require("ui/widget/confirmbox") local UIManager = require("ui/uimanager") local WidgetContainer = require("ui/widget/container/widgetcontainer") local TextViewer = require("ui/widget/textviewer") local NetworkMgr = require("ui/network/manager") local socket = require("socket") local socketutil = require("socketutil") local _ = require("gettext") local T = require("ffi/util").template local lfs = require("libs/libkoreader-lfs") -- Chat view based on TextViewer for scrollable text & menu buttons local IrcChatView = TextViewer:extend{ title = _("IRC Chat"), text = "", add_default_buttons = true, monospace_font = true, text_type = "code", keep_running = true, -- keep connection alive when UI is closed _connected = false, _closing = false, _receive_task = nil, _sock = nil, _server = nil, -- {name, host, port} _channel = nil, -- optional starting channel _nick = nil, -- Multi-target buffers _buffers = nil, -- map target => text _ordered_targets = nil, -- ordered list of targets for UI _current_target = nil, -- active target ("*" for server console) _server_label = nil, _unread = nil, -- map target => count of unseen messages -- History persistence _history_dir = nil, _history_server_dir = nil, _history_preload_lines = 500, _ui_open = false, } function IrcChatView:init(reinit) -- Mark UI as open so append/refresh paths repaint immediately self._ui_open = true -- Build buttons: first our Send row, then TextViewer's default row -- Disable TextViewer's automatic default buttons to avoid duplication self.add_default_buttons = false local send_row = { { text = _("Send"), callback = function() self:promptSendMessage() end, }, } local default_row = { { text = _("Find"), id = "find", callback = function() if self._find_next then self:findCallback() else self:findDialog() end end, hold_callback = function() if self._find_next then self:findDialog() else if self.default_hold_callback then self.default_hold_callback() end end end, }, { text = "⇱", id = "top", callback = function() if self.scroll_text_w then self.scroll_text_w:scrollToTop() end end, hold_callback = self.default_hold_callback, allow_hold_when_disabled = true, }, { text = "⇲", id = "bottom", callback = function() if self.scroll_text_w then self.scroll_text_w:scrollToBottom() end end, hold_callback = self.default_hold_callback, allow_hold_when_disabled = true, }, { text = _("Close"), callback = function() self:onClose() end, hold_callback = self.default_hold_callback, }, } self.buttons_table = { send_row, default_row } -- Buffers & title if not reinit then self._buffers = {} self._ordered_targets = {} self._current_target = nil self._unread = {} end if not self._server_label then self._server_label = self._server and (self._server.name or (self._server.host .. ":" .. tostring(self._server.port or 6667))) or "IRC" end if not reinit then -- Init history before selecting initial buffer self:initHistory() -- default to server console target self:switchTarget("*") end -- Restore last active target if available local last = self:loadLastTarget() if last and last ~= "" then self:switchTarget(last) end self:updateTitle() TextViewer.init(self, reinit) -- Ensure current buffer content is shown immediately after (re)init local tgt = self._current_target or "*" self:preloadHistory(tgt) if self.scroll_text_w and self.scroll_text_w.text_widget then self.scroll_text_w.text_widget:setText(self._buffers[tgt] or "") self.scroll_text_w:scrollToBottom() end -- Start connection after UI init so we can show logs UIManager:nextTick(function() self:startConnection() end) end -- History helpers function IrcChatView:sanitizePathPart(part) part = part or "" part = part:gsub("[^%w%._%-#@]+", "_") if part == "" then part = "_" end return part end function IrcChatView:getServerId() -- Stable ID independent of display name local host = self._server and (self._server.host or "") or "" local port = self._server and tostring(self._server.port or 6667) or "6667" local id = string.format("%s_%s", host, port) return self:sanitizePathPart(id) end function IrcChatView:getLegacyServerId() -- Old scheme: name_host_port (used briefly); try loading history from there, if present local name = self._server and (self._server.name or "") or "" local host = self._server and (self._server.host or "") or "" local port = self._server and tostring(self._server.port or 6667) or "6667" local id = string.format("%s_%s_%s", name, host, port) return self:sanitizePathPart(id) end function IrcChatView:initHistory() self._history_dir = DataStorage:getDataDir() .. "/irc_logs" lfs.mkdir(self._history_dir) self._history_server_dir = self._history_dir .. "/" .. self:getServerId() lfs.mkdir(self._history_server_dir) -- Legacy path support self._history_server_dir_legacy = self._history_dir .. "/" .. self:getLegacyServerId() end function IrcChatView:getLogPath(target) target = target or "*" local fname = target == "*" and "console" or self:sanitizePathPart(target) return string.format("%s/%s.log", self._history_server_dir, fname) end function IrcChatView:getLegacyLogPath(target) target = target or "*" local fname = target == "*" and "console" or self:sanitizePathPart(target) return string.format("%s/%s.log", self._history_server_dir_legacy, fname) end function IrcChatView:writeHistory(target, line) local path = self:getLogPath(target) local f = io.open(path, "a") if f then f:write(line, "\n") f:close() end end function IrcChatView:saveLastTarget(target) local path = string.format("%s/.last", self._history_server_dir) local f = io.open(path, "w") if f then f:write(target or "") f:close() end end function IrcChatView:loadLastTarget() local path = string.format("%s/.last", self._history_server_dir) local f = io.open(path, "r") if not f then return nil end local s = f:read("*l") f:close() return s end function IrcChatView:readLastLines(path, max_lines) local f = io.open(path, "r") if not f then return nil end local buf = {} local count = 0 for l in f:lines() do table.insert(buf, l) count = count + 1 if count > max_lines then table.remove(buf, 1) count = count - 1 end end f:close() if #buf == 0 then return nil end return table.concat(buf, "\n") end function IrcChatView:preloadHistory(target) target = target or self._current_target or "*" if self._buffers[target] and #self._buffers[target] > 0 then return end local path = self:getLogPath(target) local content = self:readLastLines(path, self._history_preload_lines) if (not content or #content == 0) and self._history_server_dir_legacy then -- Fallback to legacy path if it exists local lpath = self:getLegacyLogPath(target) content = self:readLastLines(lpath, self._history_preload_lines) end if content and #content > 0 then self._buffers[target] = content if target == (self._current_target or "*") and self.scroll_text_w and self.scroll_text_w.text_widget then self.scroll_text_w.text_widget:setText(content) end end end function IrcChatView:updateTitle() local tgt = self._current_target local new_title if tgt and tgt ~= "*" then new_title = tgt else new_title = self._server_label end self.title = new_title if self.titlebar and self.titlebar.setTitle then self.titlebar:setTitle(new_title) end end function IrcChatView:ensureBuffer(target) target = target or "*" if not self._buffers[target] then self._buffers[target] = "" table.insert(self._ordered_targets, target) end if self._unread[target] == nil then self._unread[target] = 0 end if target:sub(1,1) == "#" then self._members = self._members or {} if not self._members[target] then self._members[target] = {} end end end function IrcChatView:switchTarget(target) if not target then return end self:ensureBuffer(target) -- Preload history (if any) when switching to a buffer self:preloadHistory(target) self._current_target = target self:updateTitle() if self.scroll_text_w and self.scroll_text_w.text_widget then self.scroll_text_w.text_widget:setText(self._buffers[target] or "") self.scroll_text_w:scrollToBottom() end -- reset unread counter when focusing this target self._unread[target] = 0 -- persist last active target for this server self:saveLastTarget(target) end function IrcChatView:appendLine(line, target) if not line or line == "" then return end target = target or self._current_target or "*" self:ensureBuffer(target) local prefix = os.date("[%H:%M]") .. " " local buf = self._buffers[target] buf = (buf and #buf > 0) and (buf .. "\n" .. prefix .. line) or (prefix .. line) self._buffers[target] = buf -- Persist to history self:writeHistory(target, prefix .. line) if target == (self._current_target or "*") then if self._ui_open then -- Reuse the same refresh path as the channel switcher self:refreshView(target) end else -- increment unread counter for background target self._unread[target] = (self._unread[target] or 0) + 1 end end function IrcChatView:startConnection() if self._connected or self._closing then return end local host = self._server.host local port = tonumber(self._server.port) or 6667 self:appendLine(T(_("Connecting to %1:%2…"), host, tostring(port))) local sock, err = socket.tcp() if not sock then self:appendLine(T(_("Socket error: %1"), tostring(err))) return end -- Do a blocking connect with a short timeout, then switch to non-blocking (and TLS if needed). sock:settimeout(10, 't') local ok, cerr = sock:connect(host, port) if not ok then self:appendLine(T(_("Connect failed: %1"), tostring(cerr))) pcall(function() sock:close() end) return end local use_tls = (tonumber(port) == 6697) or (self._server.tls == true) if use_tls then local okreq, ssl = pcall(require, "ssl") if not okreq or not ssl then self:appendLine(_("TLS requested but LuaSec is unavailable.")) sock:settimeout(0, 'b') self._sock = sock else local params = { mode = "client", protocol = "tlsv1_2", verify = "none", options = "all", -- SNI support server = host, } local ssock, werr = ssl.wrap(sock, params) if not ssock then self:appendLine(T(_("TLS wrap failed: %1"), tostring(werr))) sock:settimeout(0, 'b') self._sock = sock else ssock:settimeout(10, 't') local hok, herr = ssock:dohandshake() if not hok then self:appendLine(T(_("TLS handshake failed: %1"), tostring(herr))) pcall(function() ssock:close() end) return end ssock:settimeout(0, 'b') self._sock = ssock end end else sock:settimeout(0, 'b') self._sock = sock end -- Authenticate if configured, then send NICK/USER; Join will be sent after welcome (001) self._connected = true self._registered = false self._pending_join = self._channel self:appendLine(T(_("Logging in as %1…"), self._nick)) -- PASS MUST be sent before NICK/USER if needed (e.g., ZNC) local auth_user = self._server.auth_user local auth_pass = self._server.auth_pass if auth_user and auth_pass then self:sendRaw(string.format("PASS %s:%s\r\n", auth_user, auth_pass)) elseif auth_pass and #auth_pass > 0 then -- plain server password self:sendRaw(string.format("PASS %s\r\n", auth_pass)) end self:sendRaw(string.format("NICK %s\r\n", self._nick)) self:sendRaw(string.format("USER %s 0 * :%s\r\n", self._nick, self._nick)) -- Schedule receive loop self._receive_task = function() self:receiveLoop() end UIManager:scheduleIn(0.2, self._receive_task) end function IrcChatView:sendRaw(line) if not self._sock then return end -- Best effort write pcall(function() self._sock:send(line) end) end function IrcChatView:sendMessage(msg) if not msg or msg == "" then return end if msg:sub(1,1) == "/" then -- Simple slash commands: /join, /part, /me, /msg local parts = {} for w in msg:gmatch("%S+") do table.insert(parts, w) end local cmd = parts[1]:sub(2):lower() if cmd == "join" and parts[2] then local ch = parts[2] self:sendRaw(string.format("JOIN %s\r\n", ch)) self:appendLine(T(_("Joining %1"), ch), ch) self:ensureBuffer(ch) self:switchTarget(ch) elseif cmd == "part" then local ch = parts[2] or self._current_target if ch then self:sendRaw(string.format("PART %s\r\n", ch)) self:appendLine(T(_("Leaving %1"), ch), ch) end elseif cmd == "me" and parts[2] then if self._current_target and self._current_target ~= "*" then local action = msg:match("/me%s+(.+)$") or "" self:sendRaw(string.format("PRIVMSG %s :\u{0001}ACTION %s\u{0001}\r\n", self._current_target, action)) self:appendLine(T(_("* %1 %2"), self._nick, action), self._current_target) end elseif cmd == "msg" and parts[2] and parts[3] then local target = parts[2] local text = msg:match("/msg%s+%S+%s+(.+)$") or "" self:sendRaw(string.format("PRIVMSG %s :%s\r\n", target, text)) self:appendLine(T(_("→ [%1] %2"), target, text), target) else self:appendLine(_("Unknown command.")) end return end if self._current_target and self._current_target ~= "*" then self:sendRaw(string.format("PRIVMSG %s :%s\r\n", self._current_target, msg)) self:appendLine(T(_("<%1> %2"), self._nick, msg), self._current_target) else self:appendLine(_("No channel joined. Use /join #channel")) end end function IrcChatView:promptSendMessage() local dialog dialog = InputDialog:new{ title = _("Send message"), input = "", -- Keep single-line input semantics on Enter, but expand the box use_available_height = true, -- expand input to available height condensed = true, -- reduce extra padding to maximize text area buttons = { { { text = _("Send"), is_default = true, is_enter_default = true, callback = function() local txt = dialog:getInputText() -- Update buffer/UI before closing dialog to ensure immediate repaint self:sendMessage(txt) UIManager:close(dialog) -- And schedule a focused refresh after the dialog is gone if UIManager and self._ui_open then UIManager:nextTick(function() self:refreshView(self._current_target or "*") end) end end, }, { text = _("Cancel"), callback = function() UIManager:close(dialog) end, }, } } } UIManager:show(dialog) dialog:onShowKeyboard(true) end function IrcChatView:refreshView(target) target = target or self._current_target or "*" if not self._ui_open then return end self:preloadHistory(target) if self.scroll_text_w and self.scroll_text_w.text_widget then local s = self._buffers[target] or "" self.text = s self.scroll_text_w.text_widget:setText(s) self.scroll_text_w:scrollToBottom() if self.scroll_text_w.updateScrollBar then self.scroll_text_w:updateScrollBar(true) end end if UIManager and self.frame and self.frame.dimen then UIManager:setDirty(self, function() return "ui", self.frame.dimen end) end end function IrcChatView:receiveLoop() if self._closing then return end if not self._sock then return end -- Non-blocking receive lines; iterate until no more data for i = 1, 50 do -- cap per tick local line, err, partial = self._sock:receive("*l") if line then self:handleLine(line) elseif err == "timeout" then break elseif err == "closed" then self:appendLine(_("Disconnected.")) self._closing = true pcall(function() self._sock:close() end) self._sock = nil break else if partial and #partial > 0 then self:handleLine(partial) end break end end if not self._closing then UIManager:scheduleIn(0.3, self._receive_task) end end function IrcChatView:handleLine(line) -- Handle PING/PONG and display messages -- Raw IRC line: ":prefix COMMAND params :trailing" or "PING :token" if line:match("^PING%s*:") then local token = line:match("^PING%s*:(.+)$") or "" self:sendRaw("PONG :" .. token .. "\r\n") return end local prefix, command, rest = line:match("^:([^%s]+)%s+(%S+)%s+(.+)$") if command then command = command:upper() if command == "PRIVMSG" then local target, msg = rest:match("^(%S+)%s+:(.+)$") if target and msg then local nick = prefix:match("^([^!]+)!") or prefix if target:sub(1,1) == "#" then self:appendLine(string.format("<%s> %s", nick, msg), target) -- track speaker as member self:ensureBuffer(target) self._members = self._members or {} self._members[target] = self._members[target] or {} self._members[target][nick] = true else -- PM to us self:appendLine(string.format("[%s -> you] %s", nick, msg), nick) end return end elseif command == "NOTICE" then local target, msg = rest:match("^(%S+)%s+:(.+)$") if msg then local nick = prefix and (prefix:match("^([^!]+)!") or prefix) or "*" local tgt = (target and target:sub(1,1) == "#") and target or nil self:appendLine(string.format("-%s- %s", nick, msg), tgt) return end elseif command == "JOIN" then local nick = prefix:match("^([^!]+)!") or prefix local ch = rest:match("^:(.+)$") or rest if ch and ch ~= "" then self:appendLine(string.format("* %s joined %s", nick, ch), ch) if nick == self._nick then self:ensureBuffer(ch) -- Auto-switch to first joined channel if we're still on console if self._current_target == "*" then self:switchTarget(ch) end end self:ensureBuffer(ch) self._members = self._members or {} self._members[ch] = self._members[ch] or {} self._members[ch][nick] = true end return elseif command == "PART" then local nick = prefix:match("^([^!]+)!") or prefix local ch, msg = rest:match("^(%S+)%s*:?(.*)$") if ch then self:appendLine(string.format("* %s left %s %s", nick, ch, msg or ""), ch) if self._members and self._members[ch] then self._members[ch][nick] = nil end end return elseif command == "QUIT" then local nick = prefix:match("^([^!]+)!") or prefix local msg = rest:match("^:(.+)$") or "" local shown = false if self._members then for ch, set in pairs(self._members) do if set[nick] then self:appendLine(string.format("* %s quit %s", nick, msg), ch) set[nick] = nil shown = true end end end -- If not shown anywhere, drop it to avoid cross-channel noise return elseif command == "353" then -- RPL_NAMREPLY local nickmode, chan, names = rest:match("^(%S+)%s+=%s+(%S+)%s+:(.+)$") if names then self:ensureBuffer(chan) self._members = self._members or {} self._members[chan] = self._members[chan] or {} for name in names:gmatch("%S+") do local clean = name:gsub("^[~&@%+%%]", "") self._members[chan][clean] = true end self:appendLine(T(_("Users: %1"), names), chan) end return elseif command == "001" then -- welcome (registered) local target_nick, msg = rest:match("^(%S+)%s+:(.+)$") if target_nick then self._nick = target_nick else msg = rest end self._registered = true self:appendLine(msg) if self._pending_join and #self._pending_join > 0 then self:sendRaw(string.format("JOIN %s\r\n", self._pending_join)) end return elseif command == "NICK" then -- self or others changed nick local newnick = rest:match("^:(.+)$") or rest local oldnick = prefix:match("^([^!]+)!") or prefix if oldnick == self._nick and newnick and #newnick > 0 then self._nick = newnick self:appendLine(T(_("You are now known as %1"), newnick)) end if self._members and newnick and #newnick > 0 then for ch, set in pairs(self._members) do if set[oldnick] then set[oldnick] = nil set[newnick] = true end end end return elseif command == "376" or command == "422" then -- End of MOTD / No MOTD: safe to join if not yet joined if not self._registered then self._registered = true end if self._pending_join and #self._pending_join > 0 then self:sendRaw(string.format("JOIN %s\r\n", self._pending_join)) end return elseif command == "433" then -- ERR_NICKNAMEINUSE self:appendLine(_("Nickname in use. Try changing Username in menu.")) return end end -- Fallback: show raw line trimmed self:appendLine((line:gsub("\r", ""))) end function IrcChatView:onClose() -- If keep_running, only close the UI; keep socket and receive loop alive in background if self.keep_running then self._ui_open = false TextViewer.onClose(self) return end if self._closing then return end self._closing = true if self._receive_task then UIManager:unschedule(self._receive_task) self._receive_task = nil end if self._sock then pcall(function() if self._current_target and self._current_target:sub(1,1) == "#" then self:sendRaw(string.format("PART %s\r\n", self._current_target)) end self:sendRaw("QUIT :bye\r\n") self._sock:close() end) self._sock = nil end TextViewer.onClose(self) end function IrcChatView:disconnect() -- Terminate receive loop and close socket if self._receive_task then UIManager:unschedule(self._receive_task) self._receive_task = nil end if self._sock then pcall(function() self:sendRaw("QUIT :bye\r\n") self._sock:close() end) self._sock = nil end self._connected = false self._closing = true self:appendLine(_("Disconnected.")) end -- Extend the hamburger menu to add a list of joined channels/targets. function IrcChatView:showChannelSwitcher() local Menu = require("ui/widget/menu") local items = {} local targets = self._ordered_targets or {} for i, tgt in ipairs(targets) do local label = (tgt == "*" and _("Server console")) or tgt table.insert(items, { text_func = function() local n = self._unread[tgt] or 0 if n > 0 then return string.format("%s (%d)", label, n) else return label end end, checked_func = function() return self._current_target == tgt end, callback = function(item) self:switchTarget(tgt) UIManager:close(self._switcher_menu) end, }) end self._switcher_menu = Menu:new{ title = _("Switch channel"), item_table = items, covers_fullscreen = true, is_borderless = true, is_popout = false, close_callback = function() UIManager:close(self._switcher_menu) self._switcher_menu = nil end, } UIManager:show(self._switcher_menu) end function IrcChatView:onShowMenu() local ButtonDialog = require("ui/widget/buttondialog") local SpinWidget = require("ui/widget/spinwidget") local buttons = {} table.insert(buttons, { { text = _("Switch channel"), enabled_func = function() return self._ordered_targets and #self._ordered_targets > 0 end, callback = function() self:showChannelSwitcher() end, } }) -- Disconnect action table.insert(buttons, { { text = _("Disconnect"), enabled_func = function() return self._sock ~= nil end, callback = function() self:disconnect() end, } }) -- Font size table.insert(buttons, { { text_func = function() return T(_("Font size: %1"), self.text_font_size) end, align = "left", callback = function() local widget = SpinWidget:new{ title_text = _("Font size"), value = self.text_font_size, value_min = 12, value_max = 30, default_value = self.monospace_font and 16 or 20, keep_shown_on_apply = true, callback = function(spin) self.text_font_size = spin.value self:reinit() end, } UIManager:show(widget) end, } }) -- Monospace toggle table.insert(buttons, { { text = _("Monospace font"), checked_func = function() return self.monospace_font end, align = "left", callback = function() self.monospace_font = not self.monospace_font self:reinit() end, } }) -- Justify toggle table.insert(buttons, { { text = _("Justify"), checked_func = function() return self.justified end, align = "left", callback = function() self.justified = not self.justified self:reinit() end, } }) local dialog = ButtonDialog:new{ shrink_unneeded_width = true, buttons = buttons, anchor = function() return self.titlebar.left_button.image.dimen end, } UIManager:show(dialog) end -- Main plugin local IRC = WidgetContainer:extend{ name = "irc", fullname = _("IRC client"), is_doc_only = false, settings_file = DataStorage:getSettingsDir() .. "/irc_client.lua", settings = nil, servers = nil, -- array of { name, host, port, channels = {"#chan"} } username = nil, } function IRC:onDispatcherRegisterActions() Dispatcher:registerAction("irc_open", {category="none", event="OpenIRC", title=self.fullname, general=true}) end function IRC:init() self:onDispatcherRegisterActions() self:loadSettings() self.ui.menu:registerToMainMenu(self) end function IRC:loadSettings() if self.settings then return end self.settings = LuaSettings:open(self.settings_file) self.username = self.settings:readSetting("username") or os.getenv("USER") or "koreader" self.servers = self.settings:readSetting("servers") or {} end function IRC:onFlushSettings() if not self.settings then return end self.settings:saveSetting("username", self.username) self.settings:saveSetting("servers", self.servers) self.settings:flush() end function IRC:addToMainMenu(menu_items) menu_items.irc_client = { text = self.fullname, sorting_hint = "more_tools", sub_item_table_func = function() return self:getRootMenuItems() end, } end function IRC:getRootMenuItems() local items = { { text = _("Server list"), keep_menu_open = true, sub_item_table_func = function() return self:getServerListItems() end, }, { text_func = function() return T(_("Default username: %1"), self.username) end, callback = function() self:promptUsername() end, }, } return items end function IRC:getServerDisplay(server) local label = server.name and #server.name > 0 and server.name or (server.host or "?") local port = server.port and tostring(server.port) or "6667" return string.format("%s (%s:%s)", label, server.host or "?", port) end function IRC:getServerListItems() local items = {} for idx, server in ipairs(self.servers) do table.insert(items, { text = self:getServerDisplay(server), sub_item_table = self:getServerActions(server, idx), }) end table.insert(items, { separator = true }) table.insert(items, { text = _("Add server…"), callback = function() self:promptAddServer() end, }) return items end function IRC:getServerActions(server, index) local function isConnected() return self._bg_view and self._bg_view._sock and self._bg_view._server and self._bg_view._server.host == server.host and tostring(self._bg_view._server.port or 6667) == tostring(server.port or 6667) end local actions = { { text_func = function() return isConnected() and _("Open") or _("Connect") end, callback = function() self:connectToServer(server) end, }, { text = _("Disconnect"), enabled_func = function() return isConnected() end, callback = function() if self._bg_view and self._bg_view.disconnect then self._bg_view:disconnect() end end, }, { text = _("Edit"), callback = function() self:promptEditServer(server, index) end, }, { text = _("Delete"), callback = function() UIManager:show(ConfirmBox:new{ text = T(_("Delete %1?"), self:getServerDisplay(server)), ok_text = _("Delete"), ok_callback = function() table.remove(self.servers, index) self:onFlushSettings() UIManager:show(InfoMessage:new{ text = _("Server removed."), timeout = 2 }) end, }) end, }, } return actions end function IRC:promptUsername() local dialog dialog = InputDialog:new{ title = _("Username"), input = self.username or "", buttons = { { { text = _("Save"), is_default = true, callback = function() self.username = dialog:getInputText() self:onFlushSettings() UIManager:close(dialog) end, }, { text = _("Cancel"), callback = function() UIManager:close(dialog) end, }, } } } UIManager:show(dialog) dialog:onShowKeyboard(true) end function IRC:promptAddServer() local fields = { { text = "", hint = _("Display name (optional)") }, { text = self.username or "koreader", hint = _("Nick (per-server)") }, { text = "irc.libera.chat", hint = _("Host") }, { text = "6667", hint = _("Port") }, { text = "#koreader", hint = _("Channels (comma-separated)") }, { text = "", hint = _("Auth user (optional, e.g., user or user/network)") }, { text = "", hint = _("Auth password (optional)"), text_type = "password" }, } local dialog dialog = MultiInputDialog:new{ title = _("Add server"), fields = fields, buttons = { { { text = _("Add"), is_default = true, callback = function() local name, nick, host, port_str, chans, auth_user, auth_pass = unpack(dialog:getFields()) local port = tonumber(port_str) or 6667 local channels = {} if chans and #chans > 0 then for ch in chans:gmatch("[^,%s]+") do table.insert(channels, ch) end end if not host or host == "" then UIManager:show(InfoMessage:new{ text = _("Host is required."), timeout = 2 }) return end table.insert(self.servers, { name = name, nick = (nick ~= "" and nick or nil), host = host, port = port, channels = channels, auth_user = (auth_user ~= "" and auth_user or nil), auth_pass = (auth_pass ~= "" and auth_pass or nil), }) self:onFlushSettings() UIManager:close(dialog) UIManager:show(InfoMessage:new{ text = _("Server added."), timeout = 2 }) end, }, { text = _("Cancel"), callback = function() UIManager:close(dialog) end, }, } } } UIManager:show(dialog) dialog:onShowKeyboard(true) end function IRC:promptEditServer(server, index) local fields = { { text = server.name or "", hint = _("Display name (optional)") }, { text = server.nick or self.username or "koreader", hint = _("Nick (per-server)") }, { text = server.host or "", hint = _("Host") }, { text = tostring(server.port or 6667), hint = _("Port") }, { text = table.concat(server.channels or {}, ", "), hint = _("Channels (comma-separated)") }, { text = server.auth_user or "", hint = _("Auth user (optional, e.g., user or user/network)") }, { text = server.auth_pass or "", hint = _("Auth password (optional)"), text_type = "password" }, } local dialog dialog = MultiInputDialog:new{ title = _("Edit server"), fields = fields, buttons = { { { text = _("Save"), is_default = true, callback = function() local name, nick, host, port_str, chans, auth_user, auth_pass = unpack(dialog:getFields()) local port = tonumber(port_str) or 6667 local channels = {} if chans and #chans > 0 then for ch in chans:gmatch("[^,%s]+") do table.insert(channels, ch) end end if not host or host == "" then UIManager:show(InfoMessage:new{ text = _("Host is required."), timeout = 2 }) return end server.name = name server.nick = (nick ~= "" and nick or nil) server.host = host server.port = port server.channels = channels server.auth_user = (auth_user ~= "" and auth_user or nil) server.auth_pass = (auth_pass ~= "" and auth_pass or nil) self.servers[index] = server self:onFlushSettings() UIManager:close(dialog) UIManager:show(InfoMessage:new{ text = _("Server saved."), timeout = 2 }) end, }, { text = _("Cancel"), callback = function() UIManager:close(dialog) end, }, } } } UIManager:show(dialog) dialog:onShowKeyboard(true) end function IRC:connectToServer(server) -- If multiple channels, ask to select one; otherwise connect to the only one or none local function open_chat(channel) local function do_open() local nick = server.nick or self.username or "koreader" -- Reuse background session if already running for this host/port if self._bg_view and self._bg_view._sock then if self._bg_view.reinit then self._bg_view:reinit() end self._bg_view._ui_open = true UIManager:show(self._bg_view) return end local view = IrcChatView:new{ _server = { name = server.name, host = server.host, port = server.port, auth_user = server.auth_user, auth_pass = server.auth_pass, }, _channel = channel, _nick = nick, keep_running = true, } self._bg_view = view UIManager:show(view) end -- Mark UI open self._ui_open = true -- Ensure wifi/network is up before proceeding if NetworkMgr:willRerunWhenConnected(function() open_chat(channel) end) then return end socketutil:set_timeout(socketutil.DEFAULT_BLOCK_TIMEOUT, socketutil.DEFAULT_TOTAL_TIMEOUT) do_open() end if server.channels and #server.channels > 1 then -- Prompt to pick a channel local buttons = {} for i, ch in ipairs(server.channels) do table.insert(buttons, { { text = ch, callback = function() UIManager:close(self._chan_dlg); open_chat(ch) end } }) end self._chan_dlg = require("ui/widget/buttondialog"):new{ title = _("Select channel"), buttons = buttons, } UIManager:show(self._chan_dlg) else open_chat(server.channels and server.channels[1] or nil) end end function IRC:onOpenIRC() -- Open root submenu via info message hint UIManager:show(InfoMessage:new{ text = _("Use Tools → IRC client"), timeout = 3 }) end return IRC