- 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>
1186 lines
41 KiB
Lua
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
|