Files
irc.koplugin/main.lua
Slendi fa63af7c30 irc: expand Send dialog input without allowing newlines
- Remove allow_newline; Enter triggers Send via is_enter_default.
- Keep use_available_height + condensed to reduce scroll bars.

Signed-off-by: Slendi <slendi@socopon.com>
2025-09-20 03:11:50 +03:00

1186 lines
41 KiB
Lua

-- 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