Add server messages buffer to the main screen, port desktop routing

MOTD, numerics, connection status, errors, services/server notices and all
/whois output now go to a "Server messages" box on the main screen instead of
being dropped or opening PM windows. /whois works from any channel window.
Notices to us are keyed by sender (no more window named after our own nick).
Buffer updates go through a single ordered queue, matching wIRC-desktop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWfhTzK5F5kbybEnps3VFe
This commit is contained in:
weishaupt
2026-10-11 11:08:54 +00:00
co-authored by Claude Opus 5.5
parent 393c638a5b
commit d31e8149dc
4 changed files with 381 additions and 45 deletions
@@ -73,6 +73,8 @@ class ChannelWindowActivity : ComponentActivity() {
when (command) {
"ME" -> ConnectionManager.sendChannelAction(serverId, channelName, args)
"NICK" -> ConnectionManager.sendRaw(serverId, "NICK $args")
// All whois output goes to the server messages box in the main window.
"WHOIS" -> ConnectionManager.whois(serverId, args)
"PART", "EXIT", "CLOSE" -> {
val target = args.ifBlank { channelName }
ConnectionManager.connectionFor(serverId)?.partChannel(target, "leaving")
@@ -5,7 +5,10 @@ import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
@@ -22,11 +25,19 @@ import androidx.compose.ui.input.key.isShiftPressed
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.tpuahsiew.ircwindows.data.IrcMessage
import com.tpuahsiew.ircwindows.data.MAIN_BUFFER_KEY
import com.tpuahsiew.ircwindows.data.ServerConfig
import com.tpuahsiew.ircwindows.data.ServerRepository
import com.tpuahsiew.ircwindows.irc.ConnectionManager
import com.tpuahsiew.ircwindows.irc.parseMircToAnnotatedString
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -146,6 +157,15 @@ fun MainScreen(repo: ServerRepository, onOpenChannel: (serverId: String, channel
Spacer(Modifier.height(12.dp))
}
}
// MOTD, numerics, errors, services/server notices and all /whois output - see
// ConnectionManager's MAIN_BUFFER_KEY routing.
val buffers by ConnectionManager.buffers.collectAsState()
val mainMessages = buffers[MAIN_BUFFER_KEY]?.messages ?: emptyList()
Spacer(Modifier.height(8.dp))
Text("Server messages", style = MaterialTheme.typography.labelMedium)
Spacer(Modifier.height(4.dp))
ServerMessagesBox(mainMessages, modifier = Modifier.weight(1f))
}
if (showAddDialog) {
@@ -161,6 +181,36 @@ fun MainScreen(repo: ServerRepository, onOpenChannel: (serverId: String, channel
}
}
private val serverMessageTimeFormat = SimpleDateFormat("HH:mm", Locale.getDefault())
@Composable
private fun ServerMessagesBox(messages: List<IrcMessage>, modifier: Modifier = Modifier) {
val listState = rememberLazyListState()
// Follow new lines to the bottom, the same way a channel window does.
LaunchedEffect(messages.lastOrNull()) {
if (messages.isNotEmpty()) listState.scrollToItem(messages.size - 1)
}
Box(
modifier = modifier
.fillMaxWidth()
.border(1.dp, Color.White)
.padding(6.dp)
) {
SelectionContainer {
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
items(messages) { msg ->
val ts = serverMessageTimeFormat.format(Date(msg.timestampMillis))
Text(
text = parseMircToAnnotatedString("[$ts] ${msg.rawText}", Color.White),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp
)
}
}
}
}
}
@Composable
private fun ServerCard(
server: ServerConfig,
@@ -46,3 +46,31 @@ data class ChannelBuffer(
val users: List<ChannelUser> = emptyList(),
val topic: String = ""
)
/**
* wIRC main's own buffer: server messages (MOTD, numerics, errors, all whois output) and
* services/server notices from every connected server, shown in the main window. Server
* ids are built from the host with every non-alphanumeric character replaced by '_' (see
* ServerEditDialog), so one can never contain '-' - this id can't collide with a real server.
*/
val MAIN_BUFFER_KEY = BufferKey("wirc-main", "main")
// Nicks that are network services rather than people. Their PRIVMSGs and NOTICEs go to
// wIRC main instead of opening a query window. Anything ending in "serv" is also treated
// as a service, which covers the less common ones (MemoServ, HostServ, SaslServ, ...).
private val KNOWN_SERVICE_NICKS = setOf("nickserv", "chanserv", "global", "alis", "q", "x")
fun isServiceNick(nick: String): Boolean {
val lower = nick.lowercase()
return lower in KNOWN_SERVICE_NICKS || lower.endsWith("serv")
}
/**
* True when a message's source is a service or the server itself rather than a real user.
* A user prefix always has the "nick!user@host" form; a missing prefix or one without '!'
* is a server name (e.g. "irc.rizon.io").
*/
fun isServiceOrServerPrefix(prefix: String?): Boolean {
if (prefix == null || !prefix.contains('!')) return true
return isServiceNick(prefix.substringBefore('!'))
}
@@ -6,14 +6,42 @@ import com.tpuahsiew.ircwindows.data.isChannelName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.ConcurrentHashMap
private const val MESSAGE_HISTORY_CAP = 500
// 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. 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 as a raw command).
private val WHOIS_NUMERICS = setOf(
"275", "276", "301", "307", "310", "311", "312", "313", "317", "318", "319",
"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, 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 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) 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 (Activities) read from the same source of truth via StateFlow.
@@ -33,8 +61,44 @@ object ConnectionManager {
private val _ownNicks = MutableStateFlow<Map<String, String>>(emptyMap())
val ownNicksFlow: StateFlow<Map<String, String>> = _ownNicks
// 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 -> 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>>()
// Every buffer state change - incoming lines, local echoes of our own sends - goes
// through this single FIFO queue, processed by one consumer coroutine, so lines are
// applied in the order they arrived (a whois header always lands above its replies,
// and lines in a busy channel can't commit out of order).
private val bufferOps = Channel<() -> Unit>(Channel.UNLIMITED)
init {
scope.launch(Dispatchers.IO) {
for (op in bufferOps) {
try {
op()
} catch (e: Exception) {
// One bad op must not take down the consumer loop - that would silently
// stop ALL future buffer updates for the rest of the process.
e.printStackTrace()
}
}
}
}
private fun enqueue(op: () -> Unit) {
bufferOps.trySend(op)
}
fun init(context: Context) {
if (historyRepo != null) return
historyRepo = MessageHistoryRepository(context.applicationContext)
// 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) {
@@ -42,8 +106,8 @@ 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). IrcConnectionService listens to this to
// Fires once for every brand-new private-message buffer with a real user (services
// and the server go to wIRC main instead). IrcConnectionService listens to this to
// auto-launch a window for it, same as tapping a channel manually.
private val _newQueryBuffers = MutableSharedFlow<BufferKey>(extraBufferCapacity = 16)
val newQueryBuffers: SharedFlow<BufferKey> = _newQueryBuffers
@@ -54,8 +118,12 @@ object ConnectionManager {
val conn = IrcConnection(
config = config,
scope = scope,
onLine = { line -> handleLine(config.id, line) },
onStatus = { msg -> _statuses.update { it + (config.id to msg) } }
onLine = { line -> enqueue { handleLine(config.id, line) } },
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()
@@ -74,22 +142,28 @@ object ConnectionManager {
fun sendChannelMessage(serverId: String, target: String, text: String) {
connectionFor(serverId)?.sendMessage(target, text)
val myNick = ownNicks[serverId] ?: return
addMessage(
BufferKey(serverId, target),
IrcMessage(System.currentTimeMillis(), MessageKind.CHAT, myNick, text)
)
val msg = IrcMessage(System.currentTimeMillis(), MessageKind.CHAT, myNick, text)
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
addMessage(
BufferKey(serverId, target),
IrcMessage(System.currentTimeMillis(), MessageKind.ACTION, myNick, action)
)
val msg = IrcMessage(System.currentTimeMillis(), MessageKind.ACTION, myNick, action)
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 for a real user.
private fun addOutgoing(serverId: String, target: String, msg: IrcMessage, mainText: String) {
if (!isChannelName(target) && isServiceNick(target)) {
addMainLine(MessageKind.SYSTEM, null, mainText)
} else {
addMessage(BufferKey(serverId, target), msg)
}
}
// Raw passthrough for slash commands that don't have dedicated handling.
@@ -97,33 +171,164 @@ object ConnectionManager {
connectionFor(serverId)?.send(raw)
}
/**
* 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, and may be a comma-separated list.
*/
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
}
// 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).
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", ...), or if it's about the
// WHOIS command itself (263 "try again", ...) 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 }
}
// If this key's buffer doesn't exist yet, seeds it from disk history before anything
// else touches it, so a buffer's first appearance always carries its saved history.
// Only ever runs on the single bufferOps consumer coroutine.
private fun ensureBuffer(key: BufferKey) {
if (_buffers.value.containsKey(key)) return
val seeded = historyRepo?.loadMessages(key) ?: emptyList()
_buffers.update { map -> map + (key to ChannelBuffer(key, messages = seeded)) }
}
private fun addMessage(key: BufferKey, msg: IrcMessage) {
val isNewBuffer = !_buffers.value.containsKey(key)
// Do the file I/O (seeding a brand-new buffer from disk, then persisting the
// updated list) off the caller's thread - addMessage gets called both from the
// IO-dispatched read loop and directly from UI-thread local-echo sends.
scope.launch(Dispatchers.IO) {
val seeded = if (isNewBuffer) (historyRepo?.loadMessages(key) ?: emptyList()) else emptyList()
_buffers.update { map ->
val existing = map[key] ?: ChannelBuffer(key, messages = seeded)
val trimmed = (existing.messages + msg).takeLast(500)
map + (key to existing.copy(messages = trimmed))
}
val toSave = _buffers.value[key]?.messages ?: emptyList()
historyRepo?.saveMessages(key, toSave)
ensureBuffer(key)
_buffers.update { map ->
val existing = map[key] ?: ChannelBuffer(key)
val trimmed = (existing.messages + msg).takeLast(MESSAGE_HISTORY_CAP)
map + (key to existing.copy(messages = trimmed))
}
if (isNewBuffer && !isChannelName(key.name)) {
scope.launch { _newQueryBuffers.emit(key) }
val toSave = _buffers.value[key]?.messages ?: emptyList()
historyRepo?.saveMessages(key, toSave)
if (isNewBuffer && key != MAIN_BUFFER_KEY && !isChannelName(key.name)) {
_newQueryBuffers.tryEmit(key)
}
}
private fun addMainLine(kind: MessageKind, sender: String?, text: String) {
addMessage(MAIN_BUFFER_KEY, IrcMessage(System.currentTimeMillis(), kind, sender, text))
}
private fun updateUsers(key: BufferKey, transform: (List<ChannelUser>) -> List<ChannelUser>) {
ensureBuffer(key)
_buffers.update { map ->
val existing = map[key] ?: ChannelBuffer(key)
map + (key to existing.copy(users = transform(existing.users)))
}
}
// Every WHOIS reply line goes to wIRC main, under its "WHOIS <nick>" header. A reply
// with no pending /whois (one sent as 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 = 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 {
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
@@ -132,16 +337,46 @@ object ConnectionManager {
"PRIVMSG" -> {
val target = line.params.getOrNull(0) ?: return
val text = line.params.getOrNull(1) ?: return
val isAction = text.startsWith("\u0001ACTION") && text.endsWith("\u0001")
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
// 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)
)
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.
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.
addMessage(
BufferKey(serverId, nick ?: target),
IrcMessage(now, if (isAction) MessageKind.ACTION else MessageKind.CHAT, nick, body)
)
}
}
"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 and server notices - wIRC main. One arriving
// right after a /whois is labelled as part of that whois.
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.
addMessage(BufferKey(serverId, nick ?: target), IrcMessage(now, MessageKind.NOTICE, nick, text))
}
}
"JOIN" -> {
val channel = line.params.getOrNull(0) ?: return
@@ -165,9 +400,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())
@@ -190,12 +429,13 @@ object ConnectionManager {
"TOPIC" -> {
val channel = line.params.getOrNull(0) ?: return
val topic = line.params.getOrNull(1) ?: ""
val key = BufferKey(serverId, channel)
ensureBuffer(key)
_buffers.update { map ->
val key = BufferKey(serverId, channel)
val existing = map[key] ?: ChannelBuffer(key)
map + (key to existing.copy(topic = topic))
}
addMessage(BufferKey(serverId, channel), IrcMessage(now, MessageKind.TOPIC, nick, "$nick changed topic: $topic"))
addMessage(key, IrcMessage(now, MessageKind.TOPIC, nick, "$nick changed topic: $topic"))
}
"353" -> { // NAMES reply
val channel = line.params.getOrNull(2) ?: return
@@ -209,11 +449,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
addMessage(BufferKey(serverId, target), IrcMessage(now, MessageKind.NOTICE, nick, text))
}
"NICK" -> {
val oldNick = nick ?: return
val newNick = line.params.getOrNull(0) ?: return
@@ -232,10 +467,10 @@ object ConnectionManager {
}
if (ownNicks[serverId] == oldNick) {
setOwnNick(serverId, newNick)
addMainLine(MessageKind.NICK, null, "You are now known as $newNick")
}
}
"QUIT" -> {
val reason = line.params.getOrNull(0) ?: ""
// Remove nick from every channel buffer on this server it appears in.
_buffers.update { map ->
map.mapValues { (key, buf) ->
@@ -245,6 +480,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(" ")}")
}
"PING", "PONG", "CAP" -> Unit
else -> {
when {
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.
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())
}
}
}
}
}