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:
co-authored by
Claude Opus 5.5
parent
393c638a5b
commit
d31e8149dc
@@ -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()
|
||||
ensureBuffer(key)
|
||||
_buffers.update { map ->
|
||||
val existing = map[key] ?: ChannelBuffer(key, messages = seeded)
|
||||
val trimmed = (existing.messages + msg).takeLast(500)
|
||||
val existing = map[key] ?: ChannelBuffer(key)
|
||||
val trimmed = (existing.messages + msg).takeLast(MESSAGE_HISTORY_CAP)
|
||||
map + (key to existing.copy(messages = trimmed))
|
||||
}
|
||||
val toSave = _buffers.value[key]?.messages ?: emptyList()
|
||||
historyRepo?.saveMessages(key, toSave)
|
||||
}
|
||||
if (isNewBuffer && !isChannelName(key.name)) {
|
||||
scope.launch { _newQueryBuffers.emit(key) }
|
||||
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)
|
||||
if (isChannelName(target)) {
|
||||
addMessage(
|
||||
BufferKey(serverId, bufferName),
|
||||
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) ?: ""
|
||||
_buffers.update { map ->
|
||||
val key = BufferKey(serverId, channel)
|
||||
ensureBuffer(key)
|
||||
_buffers.update { map ->
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user