Add wIRC main scrollback, route services to it, remember window bounds, bump to 0.1.7
wIRC main scrollback - The main window now has a "Server messages" scrollback using the same rendering and stick-to-bottom code as channel windows (extracted from ChannelScreen into a shared MessageScrollback composable). - MOTD, numerics, errors, connection status, user modes, INVITE, ERROR, non-ACTION CTCP requests and notices/messages from services (NickServ, ChanServ, *Serv, Global, ...) and the server go there. Its history persists like any other buffer (history/wirc-main__main.json). - Unread indicator: lines arriving while the main window is unfocused show as "N new" next to the label and in the window title/taskbar. Private messages and /whois - A PM or notice from a real user opens its query window, and reopens it if it was closed. Services and the server no longer open windows. - /whois <nick> sends WHOIS and shows the formatted reply in the window it was typed in, or in wIRC main if that window has since closed. - /me now sends a proper CTCP ACTION (the \x01 wrapper was missing). Window position and size - Main, channel and PM windows remember position and size in window_prefs.json under the existing "<serverId>__<name>" key (main uses "wirc-main__main"). The new windowX/windowY/windowWidth/ windowHeight fields are optional, so older files still load. - A saved position is only used if the window's title strip is on a connected monitor; otherwise, and for windows with no saved position, windows cascade from the primary screen's top-left with a 32dp offset. - window_prefs.json is now written via a temp file + atomic move. Cleanup - Remove the TEMP DEBUG aliasDebug tracing, ServerRepository.upsert printlns and connmgr-debug printlns. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
242d7a7c42
commit
807903fe0f
@@ -14,6 +14,9 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
// Per-buffer message history cap. Named so it's a one-line change to temporarily lower
|
||||
@@ -28,6 +31,19 @@ private const val MESSAGE_HISTORY_CAP = 500
|
||||
// reported slowness under real message volume.
|
||||
private const val SAVE_DEBOUNCE_MS = 2500L
|
||||
|
||||
// WHOIS reply numerics. Each one's params are [ourNick, targetNick, ...], so the target
|
||||
// nick (params[1]) is what ties a reply line back to the /whois that asked for it.
|
||||
// 318 is RPL_ENDOFWHOIS; 401/402 are the "no such nick/server" errors a WHOIS can get.
|
||||
private val WHOIS_NUMERICS = setOf(
|
||||
"275", "276", "301", "307", "310", "311", "312", "313", "317", "318", "319",
|
||||
"320", "330", "335", "338", "378", "379", "401", "402", "671"
|
||||
)
|
||||
|
||||
// Numerics that only make sense inside a channel window and are already reflected there
|
||||
// (353 fills the user list, see handleLine) or carry nothing worth showing on their own -
|
||||
// kept out of wIRC main so joining a channel doesn't flood it.
|
||||
private val CHANNEL_ONLY_NUMERICS = setOf("332", "333", "353", "366")
|
||||
|
||||
/**
|
||||
* App-wide singleton. Owns every IrcConnection and every ChannelBuffer, so that all
|
||||
* windows read from the same source of truth via StateFlow.
|
||||
@@ -47,8 +63,19 @@ object ConnectionManager {
|
||||
private val _ownNicks = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||
val ownNicksFlow: StateFlow<Map<String, String>> = _ownNicks
|
||||
|
||||
// Buffers that currently have a window open, kept up to date by Main.kt. Read when a
|
||||
// WHOIS reply arrives, to decide whether the window that asked is still there to show it.
|
||||
@Volatile
|
||||
var openWindows: Set<BufferKey> = emptySet()
|
||||
|
||||
// serverId -> (lowercased target nick -> the buffer whose window typed the /whois).
|
||||
private val pendingWhois = ConcurrentHashMap<String, ConcurrentHashMap<String, BufferKey>>()
|
||||
|
||||
fun init(rootDir: File) {
|
||||
historyRepo = MessageHistoryRepository(rootDir)
|
||||
// Seeds wIRC main from its saved history right away, so its scrollback is there at
|
||||
// startup instead of appearing only once the first server line arrives.
|
||||
enqueue { ensureBuffer(MAIN_BUFFER_KEY) }
|
||||
}
|
||||
|
||||
private fun setOwnNick(serverId: String, nick: String) {
|
||||
@@ -56,11 +83,12 @@ object ConnectionManager {
|
||||
_ownNicks.update { it + (serverId to nick) }
|
||||
}
|
||||
|
||||
// Fires once for every brand-new private-message buffer (a query with someone
|
||||
// who wasn't already an open window). Main.kt listens to this to auto-open a
|
||||
// window for it, same as clicking a channel manually.
|
||||
private val _newQueryBuffers = MutableSharedFlow<BufferKey>(extraBufferCapacity = 16)
|
||||
val newQueryBuffers: SharedFlow<BufferKey> = _newQueryBuffers
|
||||
// Fires for every private message to or from a real user (services and the server go
|
||||
// to wIRC main instead). Main.kt adds the key to its set of open windows, which opens
|
||||
// the query window if it isn't open - including reopening one the user closed - and
|
||||
// is a no-op if it already is.
|
||||
private val _queryWindowRequests = MutableSharedFlow<BufferKey>(extraBufferCapacity = 64)
|
||||
val queryWindowRequests: SharedFlow<BufferKey> = _queryWindowRequests
|
||||
|
||||
// Every buffer state change - incoming lines, local echoes of our own sends - goes
|
||||
// through this single FIFO queue, processed by one consumer coroutine. Previously each
|
||||
@@ -92,10 +120,6 @@ object ConnectionManager {
|
||||
}
|
||||
|
||||
fun connect(config: ServerConfig) {
|
||||
// TEMP DEBUG (see task: on-connect identify command investigation) - confirms
|
||||
// whether the config this connect() call actually received has the on-connect
|
||||
// commands the caller thinks it saved.
|
||||
println("[connmgr-debug] connect(${config.id}) called - onConnectCommands=${config.onConnectCommands}")
|
||||
val existing = connections[config.id]
|
||||
if (existing != null) {
|
||||
// This used to just `return` here, making connect() a permanent no-op for the
|
||||
@@ -105,7 +129,6 @@ object ConnectionManager {
|
||||
// `config` was captured once at construction and never replaced. Tearing down
|
||||
// the old connection and replacing it means a connect() call always actually
|
||||
// uses whatever was most recently saved.
|
||||
println("[connmgr-debug] connect(${config.id}) - already connected, replacing with a fresh connection using the latest config")
|
||||
existing.disconnect()
|
||||
}
|
||||
setOwnNick(config.id, config.nick)
|
||||
@@ -113,7 +136,11 @@ object ConnectionManager {
|
||||
config = config,
|
||||
scope = scope,
|
||||
onLine = { line -> enqueue { handleLine(config.id, line) } },
|
||||
onStatus = { msg -> _statuses.update { it + (config.id to msg) } }
|
||||
onStatus = { msg ->
|
||||
_statuses.update { it + (config.id to msg) }
|
||||
// Connection progress and errors aren't tied to any channel - wIRC main.
|
||||
enqueue { addMainLine(MessageKind.SYSTEM, null, "[${config.label}] $msg") }
|
||||
}
|
||||
)
|
||||
connections[config.id] = conn
|
||||
conn.connect()
|
||||
@@ -133,20 +160,32 @@ object ConnectionManager {
|
||||
fun sendChannelMessage(serverId: String, target: String, text: String) {
|
||||
connectionFor(serverId)?.sendMessage(target, text)
|
||||
val myNick = ownNicks[serverId] ?: return
|
||||
val key = BufferKey(serverId, target)
|
||||
val msg = IrcMessage(System.currentTimeMillis(), MessageKind.CHAT, myNick, text)
|
||||
enqueue { addMessage(key, msg) }
|
||||
enqueue { addOutgoing(serverId, target, msg, "-> *$target* $text") }
|
||||
}
|
||||
|
||||
// Same local-echo problem as sendChannelMessage, but for /me: the wire form is a
|
||||
// CTCP ACTION wrapper, while the buffer should just hold the plain action text
|
||||
// (ChannelScreen renders MessageKind.ACTION with its own "* nick ..." prefix).
|
||||
fun sendChannelAction(serverId: String, target: String, action: String) {
|
||||
connectionFor(serverId)?.sendMessage(target, "ACTION $action")
|
||||
connectionFor(serverId)?.sendMessage(target, "\u0001ACTION $action\u0001")
|
||||
val myNick = ownNicks[serverId] ?: return
|
||||
val key = BufferKey(serverId, target)
|
||||
val msg = IrcMessage(System.currentTimeMillis(), MessageKind.ACTION, myNick, action)
|
||||
enqueue { addMessage(key, msg) }
|
||||
enqueue { addOutgoing(serverId, target, msg, "-> *$target* * $myNick $action") }
|
||||
}
|
||||
|
||||
// Where a local echo goes: a channel's own buffer, wIRC main for a service (e.g.
|
||||
// "/msg NickServ ..."), or a query buffer - opening its window - for a real user.
|
||||
private fun addOutgoing(serverId: String, target: String, msg: IrcMessage, mainText: String) {
|
||||
when {
|
||||
isChannelName(target) -> addMessage(BufferKey(serverId, target), msg)
|
||||
isServiceNick(target) -> addMainLine(MessageKind.SYSTEM, null, mainText)
|
||||
else -> {
|
||||
val key = BufferKey(serverId, target)
|
||||
addMessage(key, msg)
|
||||
_queryWindowRequests.tryEmit(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Raw passthrough for slash commands that don't have dedicated handling.
|
||||
@@ -154,6 +193,22 @@ object ConnectionManager {
|
||||
connectionFor(serverId)?.send(raw)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends WHOIS and remembers which window asked, so the reply lines are shown in that
|
||||
* window - or in wIRC main if it has been closed by the time they arrive. [args] is
|
||||
* whatever followed "/whois"; its last word is the nick being looked up (the
|
||||
* "/whois server nick" form puts the nick last too).
|
||||
*/
|
||||
fun whois(serverId: String, args: String, origin: BufferKey) {
|
||||
val nick = args.trim().split(Regex("\\s+")).lastOrNull()?.takeIf { it.isNotEmpty() }
|
||||
if (nick == null) {
|
||||
addSystemMessage(serverId, origin.name, "Usage: /whois <nick>")
|
||||
return
|
||||
}
|
||||
pendingWhois.getOrPut(serverId) { ConcurrentHashMap() }[nick.lowercase()] = origin
|
||||
sendRaw(serverId, "WHOIS ${args.trim()}")
|
||||
}
|
||||
|
||||
// Local-only feedback (e.g. the /reload confirmation) that never touches the
|
||||
// wire - just appended to the buffer the same way any other SYSTEM line is.
|
||||
fun addSystemMessage(serverId: String, target: String, text: String) {
|
||||
@@ -177,12 +232,6 @@ object ConnectionManager {
|
||||
if (_buffers.value.containsKey(key)) return
|
||||
val seeded = historyRepo?.loadMessages(key) ?: emptyList()
|
||||
_buffers.update { map -> map + (key to ChannelBuffer(key, messages = seeded)) }
|
||||
if (!isChannelName(key.name)) {
|
||||
// tryEmit, not emit: this runs on the single, non-suspend bufferOps consumer,
|
||||
// not its own coroutine, so there's nothing to suspend into. The SharedFlow's
|
||||
// 16-slot extraBufferCapacity means this can't actually fail in practice.
|
||||
_newQueryBuffers.tryEmit(key)
|
||||
}
|
||||
}
|
||||
|
||||
private fun addMessage(key: BufferKey, msg: IrcMessage) {
|
||||
@@ -195,6 +244,10 @@ object ConnectionManager {
|
||||
scheduleSave(key)
|
||||
}
|
||||
|
||||
private fun addMainLine(kind: MessageKind, sender: String?, text: String) {
|
||||
addMessage(MAIN_BUFFER_KEY, IrcMessage(System.currentTimeMillis(), kind, sender, text))
|
||||
}
|
||||
|
||||
// Coalesces rapid-fire saves for the same buffer into at most one write every
|
||||
// SAVE_DEBOUNCE_MS, instead of a full history save (up to 500 messages) on every single
|
||||
// incoming message - the direct cause of reported slowness in a busy channel. Reads
|
||||
@@ -229,6 +282,57 @@ object ConnectionManager {
|
||||
}
|
||||
}
|
||||
|
||||
// A WHOIS reply line goes to the window that typed the /whois if it's still open,
|
||||
// otherwise (closed since, or a WHOIS nobody asked for here) to wIRC main.
|
||||
private fun handleWhoisLine(serverId: String, line: ParsedLine) {
|
||||
val target = line.params.getOrNull(1) ?: return
|
||||
val pending = pendingWhois[serverId]
|
||||
val origin = pending?.get(target.lowercase())
|
||||
if (line.command == "318") pending?.remove(target.lowercase())
|
||||
|
||||
val text = "[whois] " + formatWhoisLine(line)
|
||||
val msg = IrcMessage(System.currentTimeMillis(), MessageKind.SYSTEM, null, text)
|
||||
if (origin != null && origin in openWindows) addMessage(origin, msg) else addMessage(MAIN_BUFFER_KEY, msg)
|
||||
}
|
||||
|
||||
private fun formatWhoisLine(line: ParsedLine): String {
|
||||
val p = line.params
|
||||
val nick = p.getOrNull(1) ?: ""
|
||||
return when (line.command) {
|
||||
"311" -> "$nick is ${p.getOrNull(2)}@${p.getOrNull(3)} (${p.lastOrNull() ?: ""})"
|
||||
"312" -> "$nick is on ${p.getOrNull(2)} (${p.lastOrNull() ?: ""})"
|
||||
"317" -> {
|
||||
val idleSecs = p.getOrNull(2)?.toLongOrNull()
|
||||
val signon = p.getOrNull(3)?.toLongOrNull()
|
||||
buildString {
|
||||
append("$nick has been idle ${idleSecs?.let { formatDuration(it) } ?: "?"}")
|
||||
if (signon != null) {
|
||||
val fmt = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault())
|
||||
append(", signed on ${fmt.format(Date(signon * 1000))}")
|
||||
}
|
||||
}
|
||||
}
|
||||
"319" -> "$nick is on channels: ${p.lastOrNull() ?: ""}"
|
||||
"330" -> "$nick ${p.lastOrNull() ?: "is logged in as"} ${p.getOrNull(2) ?: ""}".trim()
|
||||
"301" -> "$nick is away: ${p.lastOrNull() ?: ""}"
|
||||
"318" -> "End of WHOIS for $nick"
|
||||
else -> p.drop(1).joinToString(" ")
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDuration(totalSecs: Long): String {
|
||||
val d = totalSecs / 86400
|
||||
val h = (totalSecs % 86400) / 3600
|
||||
val m = (totalSecs % 3600) / 60
|
||||
val s = totalSecs % 60
|
||||
return buildString {
|
||||
if (d > 0) append("${d}d ")
|
||||
if (d > 0 || h > 0) append("${h}h ")
|
||||
if (d > 0 || h > 0 || m > 0) append("${m}m ")
|
||||
append("${s}s")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleLine(serverId: String, line: ParsedLine) {
|
||||
val now = System.currentTimeMillis()
|
||||
val nick = nickFromPrefix(line.prefix) ?: line.prefix
|
||||
@@ -237,16 +341,46 @@ object ConnectionManager {
|
||||
"PRIVMSG" -> {
|
||||
val target = line.params.getOrNull(0) ?: return
|
||||
val text = line.params.getOrNull(1) ?: return
|
||||
val isAction = text.startsWith("ACTION") && text.endsWith("")
|
||||
val body = if (isAction) text.removePrefix("ACTION ").removeSuffix("") else text
|
||||
// Channel messages are keyed by the channel name. Private messages arrive
|
||||
// with target == our own nick, so key those by the *sender* instead -
|
||||
// that's the conversation identity a query window should use.
|
||||
val bufferName = if (isChannelName(target)) target else (nick ?: target)
|
||||
addMessage(
|
||||
BufferKey(serverId, bufferName),
|
||||
IrcMessage(now, if (isAction) MessageKind.ACTION else MessageKind.CHAT, nick, body)
|
||||
)
|
||||
val isCtcp = text.length > 1 && text.startsWith("\u0001") && text.endsWith("\u0001")
|
||||
val isAction = isCtcp && text.startsWith("\u0001ACTION")
|
||||
val body = if (isAction) text.removePrefix("\u0001ACTION ").removeSuffix("\u0001") else text
|
||||
if (isChannelName(target)) {
|
||||
addMessage(
|
||||
BufferKey(serverId, target),
|
||||
IrcMessage(now, if (isAction) MessageKind.ACTION else MessageKind.CHAT, nick, body)
|
||||
)
|
||||
} else if (isServiceOrServerPrefix(line.prefix) || (isCtcp && !isAction)) {
|
||||
// Services, the server itself, and CTCP requests other than ACTION
|
||||
// (PING, TIME, ...) go to wIRC main instead of opening a query window.
|
||||
// (VERSION is already answered and swallowed in IrcConnection.)
|
||||
val shown = if (isCtcp && !isAction) "[CTCP ${text.trim('\u0001')} from $nick]" else body
|
||||
addMainLine(MessageKind.CHAT, nick, shown)
|
||||
} else {
|
||||
// A private message from a real user arrives with target == our own
|
||||
// nick, so key it by the *sender* - that's the conversation identity a
|
||||
// query window should use - and (re)open that window.
|
||||
val key = BufferKey(serverId, nick ?: target)
|
||||
addMessage(key, IrcMessage(now, if (isAction) MessageKind.ACTION else MessageKind.CHAT, nick, body))
|
||||
_queryWindowRequests.tryEmit(key)
|
||||
}
|
||||
}
|
||||
"NOTICE" -> {
|
||||
val target = line.params.getOrNull(0) ?: return
|
||||
val text = line.params.getOrNull(1) ?: return
|
||||
if (isChannelName(target)) {
|
||||
addMessage(BufferKey(serverId, target), IrcMessage(now, MessageKind.NOTICE, nick, text))
|
||||
} else if (isServiceOrServerPrefix(line.prefix)) {
|
||||
// NickServ/ChanServ replies (identify success/failure etc.) and server
|
||||
// notices - wIRC main. The sender goes into the text because the
|
||||
// NOTICE row layout doesn't show a nick column.
|
||||
addMainLine(MessageKind.NOTICE, nick, "-${nick ?: "server"}- $text")
|
||||
} else {
|
||||
// A notice directed AT us arrives with target == our own nick, so key
|
||||
// it by the sender, same as a private message.
|
||||
val key = BufferKey(serverId, nick ?: target)
|
||||
addMessage(key, IrcMessage(now, MessageKind.NOTICE, nick, text))
|
||||
_queryWindowRequests.tryEmit(key)
|
||||
}
|
||||
}
|
||||
"JOIN" -> {
|
||||
val channel = line.params.getOrNull(0) ?: return
|
||||
@@ -270,9 +404,13 @@ object ConnectionManager {
|
||||
}
|
||||
"MODE" -> {
|
||||
val channel = line.params.getOrNull(0) ?: return
|
||||
if (!channel.startsWith("#")) return
|
||||
val modeStr = line.params.getOrNull(1) ?: ""
|
||||
val modeTarget = line.params.getOrNull(2)
|
||||
if (!isChannelName(channel)) {
|
||||
// User mode on our own nick (e.g. +i, +r after identifying) - wIRC main.
|
||||
addMainLine(MessageKind.MODE, nick, "$nick sets mode $modeStr on $channel")
|
||||
return
|
||||
}
|
||||
addMessage(
|
||||
BufferKey(serverId, channel),
|
||||
IrcMessage(now, MessageKind.MODE, nick, "$nick sets mode $modeStr ${modeTarget ?: ""}".trim())
|
||||
@@ -315,20 +453,6 @@ object ConnectionManager {
|
||||
}
|
||||
updateUsers(BufferKey(serverId, channel)) { existing -> (existing + parsedUsers).distinctBy { it.nick } }
|
||||
}
|
||||
"NOTICE" -> {
|
||||
val target = line.params.getOrNull(0) ?: return
|
||||
val text = line.params.getOrNull(1) ?: return
|
||||
// Same fix PRIVMSG already had: a notice directed AT us arrives with
|
||||
// target == our own nick, not the sender. This mattered a lot in practice -
|
||||
// NickServ/ChanServ almost universally reply to commands (IDENTIFY, etc.)
|
||||
// via NOTICE rather than PRIVMSG, so without this fallback every identify
|
||||
// success/failure notice was being filed under a buffer keyed by our OWN
|
||||
// nick - which no window ever opens for - instead of under "NickServ",
|
||||
// making it look like the automatic identify produced no feedback at all,
|
||||
// whether or not it actually worked.
|
||||
val bufferName = if (isChannelName(target)) target else (nick ?: target)
|
||||
addMessage(BufferKey(serverId, bufferName), IrcMessage(now, MessageKind.NOTICE, nick, text))
|
||||
}
|
||||
"NICK" -> {
|
||||
val oldNick = nick ?: return
|
||||
val newNick = line.params.getOrNull(0) ?: return
|
||||
@@ -347,6 +471,7 @@ object ConnectionManager {
|
||||
}
|
||||
if (ownNicks[serverId] == oldNick) {
|
||||
setOwnNick(serverId, newNick)
|
||||
addMainLine(MessageKind.NICK, null, "You are now known as $newNick")
|
||||
}
|
||||
}
|
||||
"QUIT" -> {
|
||||
@@ -360,6 +485,27 @@ object ConnectionManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
"INVITE" -> {
|
||||
val channel = line.params.getOrNull(1) ?: return
|
||||
addMainLine(MessageKind.SYSTEM, nick, "$nick invited you to $channel")
|
||||
}
|
||||
"ERROR" -> {
|
||||
addMainLine(MessageKind.SYSTEM, null, "ERROR: ${line.params.joinToString(" ")}")
|
||||
}
|
||||
"PONG", "CAP" -> Unit
|
||||
else -> {
|
||||
when {
|
||||
line.command in WHOIS_NUMERICS -> handleWhoisLine(serverId, line)
|
||||
line.command in CHANNEL_ONLY_NUMERICS -> Unit
|
||||
// Every other numeric (welcome, ISUPPORT, LUSERS, MOTD, errors, ...) is a
|
||||
// server message; params[0] is just our own nick, so it's dropped.
|
||||
line.command.all { it.isDigit() } ->
|
||||
addMainLine(MessageKind.SYSTEM, line.prefix, line.params.drop(1).joinToString(" "))
|
||||
// Anything else not tied to a channel (WALLOPS, KILL, ...) - wIRC main as-is.
|
||||
else ->
|
||||
addMainLine(MessageKind.SYSTEM, nick, "${line.command} ${line.params.joinToString(" ")}".trim())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user