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) {
|
when (command) {
|
||||||
"ME" -> ConnectionManager.sendChannelAction(serverId, channelName, args)
|
"ME" -> ConnectionManager.sendChannelAction(serverId, channelName, args)
|
||||||
"NICK" -> ConnectionManager.sendRaw(serverId, "NICK $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" -> {
|
"PART", "EXIT", "CLOSE" -> {
|
||||||
val target = args.ifBlank { channelName }
|
val target = args.ifBlank { channelName }
|
||||||
ConnectionManager.connectionFor(serverId)?.partChannel(target, "leaving")
|
ConnectionManager.connectionFor(serverId)?.partChannel(target, "leaving")
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import android.content.Intent
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.layout.*
|
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.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
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.key
|
||||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
import androidx.compose.ui.input.key.type
|
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.text.input.ImeAction
|
||||||
import androidx.compose.ui.unit.dp
|
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.ServerConfig
|
||||||
import com.tpuahsiew.ircwindows.data.ServerRepository
|
import com.tpuahsiew.ircwindows.data.ServerRepository
|
||||||
import com.tpuahsiew.ircwindows.irc.ConnectionManager
|
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.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@@ -146,6 +157,15 @@ fun MainScreen(repo: ServerRepository, onOpenChannel: (serverId: String, channel
|
|||||||
Spacer(Modifier.height(12.dp))
|
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) {
|
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
|
@Composable
|
||||||
private fun ServerCard(
|
private fun ServerCard(
|
||||||
server: ServerConfig,
|
server: ServerConfig,
|
||||||
|
|||||||
@@ -46,3 +46,31 @@ data class ChannelBuffer(
|
|||||||
val users: List<ChannelUser> = emptyList(),
|
val users: List<ChannelUser> = emptyList(),
|
||||||
val topic: String = ""
|
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.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
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
|
* App-wide singleton. Owns every IrcConnection and every ChannelBuffer, so that all
|
||||||
* windows (Activities) read from the same source of truth via StateFlow.
|
* 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())
|
private val _ownNicks = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||||
val ownNicksFlow: StateFlow<Map<String, String>> = _ownNicks
|
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) {
|
fun init(context: Context) {
|
||||||
|
if (historyRepo != null) return
|
||||||
historyRepo = MessageHistoryRepository(context.applicationContext)
|
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) {
|
private fun setOwnNick(serverId: String, nick: String) {
|
||||||
@@ -42,8 +106,8 @@ object ConnectionManager {
|
|||||||
_ownNicks.update { it + (serverId to nick) }
|
_ownNicks.update { it + (serverId to nick) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fires once for every brand-new private-message buffer (a query with someone
|
// Fires once for every brand-new private-message buffer with a real user (services
|
||||||
// who wasn't already an open window). IrcConnectionService listens to this to
|
// 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.
|
// auto-launch a window for it, same as tapping a channel manually.
|
||||||
private val _newQueryBuffers = MutableSharedFlow<BufferKey>(extraBufferCapacity = 16)
|
private val _newQueryBuffers = MutableSharedFlow<BufferKey>(extraBufferCapacity = 16)
|
||||||
val newQueryBuffers: SharedFlow<BufferKey> = _newQueryBuffers
|
val newQueryBuffers: SharedFlow<BufferKey> = _newQueryBuffers
|
||||||
@@ -54,8 +118,12 @@ object ConnectionManager {
|
|||||||
val conn = IrcConnection(
|
val conn = IrcConnection(
|
||||||
config = config,
|
config = config,
|
||||||
scope = scope,
|
scope = scope,
|
||||||
onLine = { line -> handleLine(config.id, line) },
|
onLine = { line -> enqueue { handleLine(config.id, line) } },
|
||||||
onStatus = { msg -> _statuses.update { it + (config.id to msg) } }
|
onStatus = { msg ->
|
||||||
|
_statuses.update { it + (config.id to msg) }
|
||||||
|
// Connection progress and errors aren't tied to any channel - wIRC main.
|
||||||
|
enqueue { addMainLine(MessageKind.SYSTEM, null, "[${config.label}] $msg") }
|
||||||
|
}
|
||||||
)
|
)
|
||||||
connections[config.id] = conn
|
connections[config.id] = conn
|
||||||
conn.connect()
|
conn.connect()
|
||||||
@@ -74,22 +142,28 @@ object ConnectionManager {
|
|||||||
fun sendChannelMessage(serverId: String, target: String, text: String) {
|
fun sendChannelMessage(serverId: String, target: String, text: String) {
|
||||||
connectionFor(serverId)?.sendMessage(target, text)
|
connectionFor(serverId)?.sendMessage(target, text)
|
||||||
val myNick = ownNicks[serverId] ?: return
|
val myNick = ownNicks[serverId] ?: return
|
||||||
addMessage(
|
val msg = IrcMessage(System.currentTimeMillis(), MessageKind.CHAT, myNick, text)
|
||||||
BufferKey(serverId, target),
|
enqueue { addOutgoing(serverId, target, msg, "-> *$target* $text") }
|
||||||
IrcMessage(System.currentTimeMillis(), MessageKind.CHAT, myNick, text)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same local-echo problem as sendChannelMessage, but for /me: the wire form is a
|
// 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
|
// CTCP ACTION wrapper, while the buffer should just hold the plain action text
|
||||||
// (ChannelScreen renders MessageKind.ACTION with its own "* nick ..." prefix).
|
// (ChannelScreen renders MessageKind.ACTION with its own "* nick ..." prefix).
|
||||||
fun sendChannelAction(serverId: String, target: String, action: String) {
|
fun sendChannelAction(serverId: String, target: String, action: String) {
|
||||||
connectionFor(serverId)?.sendMessage(target, "ACTION $action")
|
connectionFor(serverId)?.sendMessage(target, "\u0001ACTION $action\u0001")
|
||||||
val myNick = ownNicks[serverId] ?: return
|
val myNick = ownNicks[serverId] ?: return
|
||||||
addMessage(
|
val msg = IrcMessage(System.currentTimeMillis(), MessageKind.ACTION, myNick, action)
|
||||||
BufferKey(serverId, target),
|
enqueue { addOutgoing(serverId, target, msg, "-> *$target* * $myNick $action") }
|
||||||
IrcMessage(System.currentTimeMillis(), MessageKind.ACTION, 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.
|
// Raw passthrough for slash commands that don't have dedicated handling.
|
||||||
@@ -97,33 +171,164 @@ object ConnectionManager {
|
|||||||
connectionFor(serverId)?.send(raw)
|
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) {
|
private fun addMessage(key: BufferKey, msg: IrcMessage) {
|
||||||
val isNewBuffer = !_buffers.value.containsKey(key)
|
val isNewBuffer = !_buffers.value.containsKey(key)
|
||||||
// Do the file I/O (seeding a brand-new buffer from disk, then persisting the
|
ensureBuffer(key)
|
||||||
// updated list) off the caller's thread - addMessage gets called both from the
|
_buffers.update { map ->
|
||||||
// IO-dispatched read loop and directly from UI-thread local-echo sends.
|
val existing = map[key] ?: ChannelBuffer(key)
|
||||||
scope.launch(Dispatchers.IO) {
|
val trimmed = (existing.messages + msg).takeLast(MESSAGE_HISTORY_CAP)
|
||||||
val seeded = if (isNewBuffer) (historyRepo?.loadMessages(key) ?: emptyList()) else emptyList()
|
map + (key to existing.copy(messages = trimmed))
|
||||||
_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)
|
|
||||||
}
|
}
|
||||||
if (isNewBuffer && !isChannelName(key.name)) {
|
val toSave = _buffers.value[key]?.messages ?: emptyList()
|
||||||
scope.launch { _newQueryBuffers.emit(key) }
|
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>) {
|
private fun updateUsers(key: BufferKey, transform: (List<ChannelUser>) -> List<ChannelUser>) {
|
||||||
|
ensureBuffer(key)
|
||||||
_buffers.update { map ->
|
_buffers.update { map ->
|
||||||
val existing = map[key] ?: ChannelBuffer(key)
|
val existing = map[key] ?: ChannelBuffer(key)
|
||||||
map + (key to existing.copy(users = transform(existing.users)))
|
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) {
|
private fun handleLine(serverId: String, line: ParsedLine) {
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
val nick = nickFromPrefix(line.prefix) ?: line.prefix
|
val nick = nickFromPrefix(line.prefix) ?: line.prefix
|
||||||
@@ -132,16 +337,46 @@ object ConnectionManager {
|
|||||||
"PRIVMSG" -> {
|
"PRIVMSG" -> {
|
||||||
val target = line.params.getOrNull(0) ?: return
|
val target = line.params.getOrNull(0) ?: return
|
||||||
val text = line.params.getOrNull(1) ?: 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
|
val body = if (isAction) text.removePrefix("\u0001ACTION ").removeSuffix("\u0001") else text
|
||||||
// Channel messages are keyed by the channel name. Private messages arrive
|
if (isChannelName(target)) {
|
||||||
// with target == our own nick, so key those by the *sender* instead -
|
addMessage(
|
||||||
// that's the conversation identity a query window should use.
|
BufferKey(serverId, target),
|
||||||
val bufferName = if (isChannelName(target)) target else (nick ?: target)
|
IrcMessage(now, if (isAction) MessageKind.ACTION else MessageKind.CHAT, nick, body)
|
||||||
addMessage(
|
)
|
||||||
BufferKey(serverId, bufferName),
|
} else if (isServiceOrServerPrefix(line.prefix) || (isCtcp && !isAction)) {
|
||||||
IrcMessage(now, if (isAction) MessageKind.ACTION else MessageKind.CHAT, nick, body)
|
// 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" -> {
|
"JOIN" -> {
|
||||||
val channel = line.params.getOrNull(0) ?: return
|
val channel = line.params.getOrNull(0) ?: return
|
||||||
@@ -165,9 +400,13 @@ object ConnectionManager {
|
|||||||
}
|
}
|
||||||
"MODE" -> {
|
"MODE" -> {
|
||||||
val channel = line.params.getOrNull(0) ?: return
|
val channel = line.params.getOrNull(0) ?: return
|
||||||
if (!channel.startsWith("#")) return
|
|
||||||
val modeStr = line.params.getOrNull(1) ?: ""
|
val modeStr = line.params.getOrNull(1) ?: ""
|
||||||
val modeTarget = line.params.getOrNull(2)
|
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(
|
addMessage(
|
||||||
BufferKey(serverId, channel),
|
BufferKey(serverId, channel),
|
||||||
IrcMessage(now, MessageKind.MODE, nick, "$nick sets mode $modeStr ${modeTarget ?: ""}".trim())
|
IrcMessage(now, MessageKind.MODE, nick, "$nick sets mode $modeStr ${modeTarget ?: ""}".trim())
|
||||||
@@ -190,12 +429,13 @@ object ConnectionManager {
|
|||||||
"TOPIC" -> {
|
"TOPIC" -> {
|
||||||
val channel = line.params.getOrNull(0) ?: return
|
val channel = line.params.getOrNull(0) ?: return
|
||||||
val topic = line.params.getOrNull(1) ?: ""
|
val topic = line.params.getOrNull(1) ?: ""
|
||||||
|
val key = BufferKey(serverId, channel)
|
||||||
|
ensureBuffer(key)
|
||||||
_buffers.update { map ->
|
_buffers.update { map ->
|
||||||
val key = BufferKey(serverId, channel)
|
|
||||||
val existing = map[key] ?: ChannelBuffer(key)
|
val existing = map[key] ?: ChannelBuffer(key)
|
||||||
map + (key to existing.copy(topic = topic))
|
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
|
"353" -> { // NAMES reply
|
||||||
val channel = line.params.getOrNull(2) ?: return
|
val channel = line.params.getOrNull(2) ?: return
|
||||||
@@ -209,11 +449,6 @@ object ConnectionManager {
|
|||||||
}
|
}
|
||||||
updateUsers(BufferKey(serverId, channel)) { existing -> (existing + parsedUsers).distinctBy { it.nick } }
|
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" -> {
|
"NICK" -> {
|
||||||
val oldNick = nick ?: return
|
val oldNick = nick ?: return
|
||||||
val newNick = line.params.getOrNull(0) ?: return
|
val newNick = line.params.getOrNull(0) ?: return
|
||||||
@@ -232,10 +467,10 @@ object ConnectionManager {
|
|||||||
}
|
}
|
||||||
if (ownNicks[serverId] == oldNick) {
|
if (ownNicks[serverId] == oldNick) {
|
||||||
setOwnNick(serverId, newNick)
|
setOwnNick(serverId, newNick)
|
||||||
|
addMainLine(MessageKind.NICK, null, "You are now known as $newNick")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"QUIT" -> {
|
"QUIT" -> {
|
||||||
val reason = line.params.getOrNull(0) ?: ""
|
|
||||||
// Remove nick from every channel buffer on this server it appears in.
|
// Remove nick from every channel buffer on this server it appears in.
|
||||||
_buffers.update { map ->
|
_buffers.update { map ->
|
||||||
map.mapValues { (key, buf) ->
|
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