Add scrollback text selection/copy, route all /whois output to wIRC main, bump to 0.1.8
- Scrollback (channel windows and wIRC main): mouse drag selection, double-click word, triple-click line, shift-click extend, Ctrl+C / Ctrl+Insert, Ctrl+A, Esc, and a right-click menu (Copy / Copy line / Select all). Copies one line per message. Clipboard writes go off the UI thread with a timeout, like the paste handler. Auto-scroll pauses while selecting and catches up once the selection is cleared. - /whois: header, every reply line, related numerics (401/402/716/263...) and server or services notices arriving right after it all go to wIRC main under a bold "===== WHOIS <nick> (<server>) =====" header, counted by its unread indicator. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
807903fe0f
commit
d9a0297cc7
@@ -33,12 +33,22 @@ 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.
|
||||
// 318 is RPL_ENDOFWHOIS. These only ever come from a WHOIS, so they're shown as whois
|
||||
// output even when no /whois of ours is pending (e.g. one sent through an alias).
|
||||
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"
|
||||
"320", "330", "335", "338", "378", "379", "671"
|
||||
)
|
||||
|
||||
// How long after a /whois is sent that a NOTICE from the server or services still counts
|
||||
// as a response to it (e.g. a server-side ignore / caller-id notice), and so is labelled
|
||||
// and grouped under that whois in wIRC main.
|
||||
private const val WHOIS_NOTICE_WINDOW_MS = 10_000L
|
||||
|
||||
// A /whois that never got its 318 (lost reply, server that doesn't send one) stops being
|
||||
// pending after this long, so stray numerics much later aren't attributed to it.
|
||||
private const val WHOIS_PENDING_TIMEOUT_MS = 60_000L
|
||||
|
||||
// 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.
|
||||
@@ -63,13 +73,13 @@ 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()
|
||||
// A /whois we sent and haven't seen the end of yet. headerShown tracks whether its
|
||||
// "WHOIS <nick>" header line is already in wIRC main.
|
||||
private class PendingWhois(val nick: String, val sentAtMillis: Long, var headerShown: Boolean)
|
||||
|
||||
// serverId -> (lowercased target nick -> the buffer whose window typed the /whois).
|
||||
private val pendingWhois = ConcurrentHashMap<String, ConcurrentHashMap<String, BufferKey>>()
|
||||
// serverId -> (lowercased target nick -> pending whois). Only ever touched from the
|
||||
// bufferOps consumer coroutine (see enqueue), so a plain map is enough.
|
||||
private val pendingWhois = HashMap<String, MutableMap<String, PendingWhois>>()
|
||||
|
||||
fun init(rootDir: File) {
|
||||
historyRepo = MessageHistoryRepository(rootDir)
|
||||
@@ -194,21 +204,70 @@ object ConnectionManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Sends WHOIS. All of its output - the header, every reply line, and any server or
|
||||
* services notice that comes back in response - goes to wIRC main, whichever window the
|
||||
* command was typed in. [args] is whatever followed "/whois"; its last word is the nick
|
||||
* being looked up (the "/whois server nick" form puts the nick last too), and may be a
|
||||
* comma-separated list.
|
||||
*/
|
||||
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>")
|
||||
fun whois(serverId: String, args: String) {
|
||||
val label = connectionFor(serverId)?.config?.label
|
||||
val nicks = args.trim().split(Regex("\\s+")).lastOrNull()
|
||||
?.split(',')?.filter { it.isNotEmpty() }.orEmpty()
|
||||
if (nicks.isEmpty() || label == null) {
|
||||
val text = if (nicks.isEmpty()) "[whois] Usage: /whois <nick>" else "[whois] Not connected to this server"
|
||||
enqueue { addMainLine(MessageKind.SYSTEM, null, text) }
|
||||
return
|
||||
}
|
||||
pendingWhois.getOrPut(serverId) { ConcurrentHashMap() }[nick.lowercase()] = origin
|
||||
// Enqueued before the WHOIS goes out, so the header is always in the buffer ahead
|
||||
// of any reply (replies come in through the same queue, and can't arrive before
|
||||
// the command is sent). Each further nick of a comma list gets its header when its
|
||||
// first reply line arrives, so the lines end up grouped under the right one.
|
||||
enqueue {
|
||||
val now = System.currentTimeMillis()
|
||||
val pending = pendingWhois.getOrPut(serverId) { HashMap() }
|
||||
nicks.forEach { pending[it.lowercase()] = PendingWhois(it, now, headerShown = false) }
|
||||
showWhoisHeader(serverId, pending.getValue(nicks.first().lowercase()))
|
||||
}
|
||||
sendRaw(serverId, "WHOIS ${args.trim()}")
|
||||
}
|
||||
|
||||
private fun showWhoisHeader(serverId: String, pending: PendingWhois) {
|
||||
if (pending.headerShown) return
|
||||
pending.headerShown = true
|
||||
val label = connectionFor(serverId)?.config?.label ?: serverId
|
||||
// \u0002 is the mIRC bold toggle, so the header stands out from the reply lines.
|
||||
addMainLine(MessageKind.SYSTEM, null, "\u0002===== WHOIS ${pending.nick} ($label) =====\u0002")
|
||||
}
|
||||
|
||||
// Pending whois for this server, with any that timed out dropped first.
|
||||
private fun livePendingWhois(serverId: String): MutableMap<String, PendingWhois>? {
|
||||
val pending = pendingWhois[serverId] ?: return null
|
||||
val cutoff = System.currentTimeMillis() - WHOIS_PENDING_TIMEOUT_MS
|
||||
pending.values.removeAll { it.sentAtMillis < cutoff }
|
||||
return pending
|
||||
}
|
||||
|
||||
// A numeric is whois output if it's one of WHOIS_NUMERICS, or if it names a nick we
|
||||
// have a /whois pending for (401/402 "no such nick/server", 716 "is in +g mode
|
||||
// (server-side ignore)", ...), or if it's about the WHOIS command itself (263
|
||||
// "try again", 481, ...) while one is pending.
|
||||
private fun isWhoisNumeric(serverId: String, line: ParsedLine): Boolean {
|
||||
if (line.command in WHOIS_NUMERICS) return true
|
||||
if (!line.command.all { it.isDigit() }) return false
|
||||
val pending = livePendingWhois(serverId)
|
||||
if (pending.isNullOrEmpty()) return false
|
||||
val subject = line.params.getOrNull(1) ?: return false
|
||||
return subject.lowercase() in pending || subject.equals("WHOIS", ignoreCase = true)
|
||||
}
|
||||
|
||||
// The most recently sent /whois on this server, if it went out recently enough that a
|
||||
// server or services NOTICE arriving now is plausibly a response to it.
|
||||
private fun whoisAwaitingNotice(serverId: String): PendingWhois? {
|
||||
val recent = livePendingWhois(serverId)?.values?.maxByOrNull { it.sentAtMillis } ?: return null
|
||||
return recent.takeIf { System.currentTimeMillis() - it.sentAtMillis <= WHOIS_NOTICE_WINDOW_MS }
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -282,17 +341,27 @@ 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.
|
||||
// Every WHOIS reply line goes to wIRC main, under its "WHOIS <nick>" header. A reply
|
||||
// with no pending /whois (one sent through an alias or a raw command) gets a header
|
||||
// of its own the same way, so every whois result in wIRC main starts with one.
|
||||
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)
|
||||
val pending = livePendingWhois(serverId)
|
||||
val entry = pending?.get(target.lowercase())
|
||||
?: if (target.equals("WHOIS", ignoreCase = true)) pending?.values?.maxByOrNull { it.sentAtMillis } else null
|
||||
when {
|
||||
entry != null -> showWhoisHeader(serverId, entry)
|
||||
line.command in WHOIS_NUMERICS && line.command != "318" -> {
|
||||
val unsolicited = PendingWhois(target, System.currentTimeMillis(), headerShown = false)
|
||||
pendingWhois.getOrPut(serverId) { HashMap() }[target.lowercase()] = unsolicited
|
||||
showWhoisHeader(serverId, unsolicited)
|
||||
}
|
||||
}
|
||||
if (line.command == "318") {
|
||||
// A comma-list whois ends with a single 318 naming the whole list.
|
||||
target.split(',').forEach { pending?.remove(it.lowercase()) }
|
||||
}
|
||||
addMainLine(MessageKind.SYSTEM, null, "[whois] " + formatWhoisLine(line))
|
||||
}
|
||||
|
||||
private fun formatWhoisLine(line: ParsedLine): String {
|
||||
@@ -372,8 +441,13 @@ object ConnectionManager {
|
||||
} 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")
|
||||
// NOTICE row layout doesn't show a nick column. One arriving right
|
||||
// after a /whois (e.g. a server-side ignore notice) is labelled as part
|
||||
// of that whois and goes under its header.
|
||||
val whois = whoisAwaitingNotice(serverId)
|
||||
if (whois != null) showWhoisHeader(serverId, whois)
|
||||
val label = if (whois != null) "[whois] " else ""
|
||||
addMainLine(MessageKind.NOTICE, nick, "$label-${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.
|
||||
@@ -495,7 +569,7 @@ object ConnectionManager {
|
||||
"PONG", "CAP" -> Unit
|
||||
else -> {
|
||||
when {
|
||||
line.command in WHOIS_NUMERICS -> handleWhoisLine(serverId, line)
|
||||
isWhoisNumeric(serverId, line) -> 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.
|
||||
|
||||
Reference in New Issue
Block a user