diff --git a/app/src/main/java/com/tpuahsiew/ircwindows/ChannelWindowActivity.kt b/app/src/main/java/com/tpuahsiew/ircwindows/ChannelWindowActivity.kt index 834157e..5048352 100644 --- a/app/src/main/java/com/tpuahsiew/ircwindows/ChannelWindowActivity.kt +++ b/app/src/main/java/com/tpuahsiew/ircwindows/ChannelWindowActivity.kt @@ -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") diff --git a/app/src/main/java/com/tpuahsiew/ircwindows/MainActivity.kt b/app/src/main/java/com/tpuahsiew/ircwindows/MainActivity.kt index baeb0da..e752b6f 100644 --- a/app/src/main/java/com/tpuahsiew/ircwindows/MainActivity.kt +++ b/app/src/main/java/com/tpuahsiew/ircwindows/MainActivity.kt @@ -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, 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, diff --git a/app/src/main/java/com/tpuahsiew/ircwindows/data/Models.kt b/app/src/main/java/com/tpuahsiew/ircwindows/data/Models.kt index 4fd1b0d..4f7274d 100644 --- a/app/src/main/java/com/tpuahsiew/ircwindows/data/Models.kt +++ b/app/src/main/java/com/tpuahsiew/ircwindows/data/Models.kt @@ -46,3 +46,31 @@ data class ChannelBuffer( val users: List = 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('!')) +} diff --git a/app/src/main/java/com/tpuahsiew/ircwindows/irc/ConnectionManager.kt b/app/src/main/java/com/tpuahsiew/ircwindows/irc/ConnectionManager.kt index 03701c9..bfc45cf 100644 --- a/app/src/main/java/com/tpuahsiew/ircwindows/irc/ConnectionManager.kt +++ b/app/src/main/java/com/tpuahsiew/ircwindows/irc/ConnectionManager.kt @@ -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>(emptyMap()) val ownNicksFlow: StateFlow> = _ownNicks + // A /whois we sent and haven't seen the end of yet. headerShown tracks whether its + // "WHOIS " 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>() + + // 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(extraBufferCapacity = 16) val newQueryBuffers: SharedFlow = _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 " 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? { + 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) -> List) { + 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 " 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()) + } + } } } }