Add wIRC main scrollback, route services to it, remember window bounds, bump to 0.1.7

wIRC main scrollback
- The main window now has a "Server messages" scrollback using the same
  rendering and stick-to-bottom code as channel windows (extracted from
  ChannelScreen into a shared MessageScrollback composable).
- MOTD, numerics, errors, connection status, user modes, INVITE, ERROR,
  non-ACTION CTCP requests and notices/messages from services
  (NickServ, ChanServ, *Serv, Global, ...) and the server go there. Its
  history persists like any other buffer (history/wirc-main__main.json).
- Unread indicator: lines arriving while the main window is unfocused
  show as "N new" next to the label and in the window title/taskbar.

Private messages and /whois
- A PM or notice from a real user opens its query window, and reopens it
  if it was closed. Services and the server no longer open windows.
- /whois <nick> sends WHOIS and shows the formatted reply in the window
  it was typed in, or in wIRC main if that window has since closed.
- /me now sends a proper CTCP ACTION (the \x01 wrapper was missing).

Window position and size
- Main, channel and PM windows remember position and size in
  window_prefs.json under the existing "<serverId>__<name>" key (main
  uses "wirc-main__main"). The new windowX/windowY/windowWidth/
  windowHeight fields are optional, so older files still load.
- A saved position is only used if the window's title strip is on a
  connected monitor; otherwise, and for windows with no saved position,
  windows cascade from the primary screen's top-left with a 32dp offset.
- window_prefs.json is now written via a temp file + atomic move.

Cleanup
- Remove the TEMP DEBUG aliasDebug tracing, ServerRepository.upsert
  printlns and connmgr-debug printlns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
weishaupt
2026-10-05 15:08:38 -04:00
co-authored by Claude Opus 5.5
parent 242d7a7c42
commit 807903fe0f
11 changed files with 866 additions and 389 deletions
+2 -2
View File
@@ -6,7 +6,7 @@ plugins {
} }
group = "com.tpuahsiew.wirc" group = "com.tpuahsiew.wirc"
version = "0.1.6" version = "0.1.7"
// No project-level repositories{} block here: settings.gradle.kts sets // No project-level repositories{} block here: settings.gradle.kts sets
// dependencyResolutionManagement.repositoriesMode = FAIL_ON_PROJECT_REPOS and already // dependencyResolutionManagement.repositoriesMode = FAIL_ON_PROJECT_REPOS and already
@@ -38,7 +38,7 @@ compose.desktop {
appResourcesRootDir.set(project.file("installerResources")) appResourcesRootDir.set(project.file("installerResources"))
targetFormats(TargetFormat.Msi, TargetFormat.Exe) targetFormats(TargetFormat.Msi, TargetFormat.Exe)
packageName = "wIRC" packageName = "wIRC"
packageVersion = "0.1.6" packageVersion = "0.1.7"
windows { windows {
menuGroup = "wIRC" menuGroup = "wIRC"
upgradeUuid = "8f14e45f-ceea-4a5e-9c3b-5b1f0e8c2b7a" upgradeUuid = "8f14e45f-ceea-4a5e-9c3b-5b1f0e8c2b7a"
+30 -5
View File
@@ -2,6 +2,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -9,16 +10,20 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState
import data.AliasRepository import data.AliasRepository
import data.BufferKey import data.BufferKey
import data.MAIN_BUFFER_KEY
import data.ServerRepository import data.ServerRepository
import data.WindowPrefsRepository import data.WindowPrefsRepository
import data.storageId
import irc.ConnectionManager import irc.ConnectionManager
import ui.ChannelWindow import ui.ChannelWindow
import ui.MainScreen import ui.MainScreen
import ui.rememberPersistentWindowState
import java.io.File import java.io.File
// Material 3's darkColorScheme() defaults to a purple accent; this keeps everything // Material 3's darkColorScheme() defaults to a purple accent; this keeps everything
@@ -51,6 +56,13 @@ fun main() = application {
val prefsRepo = remember { WindowPrefsRepository(appRoot) } val prefsRepo = remember { WindowPrefsRepository(appRoot) }
var openBuffers by remember { mutableStateOf(setOf<BufferKey>()) } var openBuffers by remember { mutableStateOf(setOf<BufferKey>()) }
var mainUnread by remember { mutableStateOf(0) }
// ConnectionManager routes WHOIS replies to the window that asked only while that
// window is still open, so it needs to know which ones are.
LaunchedEffect(openBuffers) {
ConnectionManager.openWindows = openBuffers
}
// Full shutdown: same steps /quit (bare, no args) already runs from a channel window - // Full shutdown: same steps /quit (bare, no args) already runs from a channel window -
// disconnect every server, then end the process. Shared here so the "Exit wIRC" button // disconnect every server, then end the process. Shared here so the "Exit wIRC" button
@@ -65,31 +77,44 @@ fun main() = application {
// ConnectionManager's sockets just live for as long as this process runs, and this // ConnectionManager's sockets just live for as long as this process runs, and this
// listener (which the Android foreground service used to own) can live directly at // listener (which the Android foreground service used to own) can live directly at
// the application level instead. // the application level instead.
//
// Fires for every private message to or from a real user, so a query window the user
// closed reopens when that person messages again. Adding a key that's already open is
// a no-op (openBuffers is a set).
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
ConnectionManager.newQueryBuffers.collect { key -> ConnectionManager.queryWindowRequests.collect { key ->
openBuffers = openBuffers + key openBuffers = openBuffers + key
} }
} }
// Restored from / saved to window_prefs.json under MAIN_BUFFER_KEY's storage id
// ("wirc-main__main"), same as every channel and PM window - see WindowPlacement.kt.
val mainWindowState = rememberPersistentWindowState(prefsRepo, MAIN_BUFFER_KEY.storageId(), DpSize(800.dp, 700.dp))
val mainDisplaySettings by prefsRepo.settingsFor(MAIN_BUFFER_KEY.storageId()).collectAsState()
// There's no persistent tray/notification surface on this build, so closing the main // There's no persistent tray/notification surface on this build, so closing the main
// server-list window quits the whole app (and every channel window with it) - the // server-list window quits the whole app (and every channel window with it) - the
// same "closing the primary window ends the session" convention most desktop chat // same "closing the primary window ends the session" convention most desktop chat
// apps without a tray icon follow. // apps without a tray icon follow.
Window( Window(
onCloseRequest = ::exitApplication, onCloseRequest = ::exitApplication,
title = "wIRC - Main", // The unread count shows in the taskbar too, so new server messages are visible
// even when the main window is behind other windows.
title = if (mainUnread > 0) "wIRC - Main ($mainUnread new)" else "wIRC - Main",
icon = painterResource("icon.png"), icon = painterResource("icon.png"),
state = rememberWindowState() state = mainWindowState
) { ) {
MaterialTheme(colorScheme = neutralDarkScheme) { MaterialTheme(colorScheme = neutralDarkScheme) {
Surface { Surface {
MainScreen( MainScreen(
repo = repo, repo = repo,
colorScheme = neutralDarkScheme, colorScheme = neutralDarkScheme,
displaySettings = mainDisplaySettings,
onOpenChannel = { serverId, channelName -> onOpenChannel = { serverId, channelName ->
openBuffers = openBuffers + BufferKey(serverId, channelName) openBuffers = openBuffers + BufferKey(serverId, channelName)
}, },
onExitApp = shutdownApp onExitApp = shutdownApp,
onUnreadChange = { mainUnread = it }
) )
} }
} }
+4 -18
View File
@@ -5,13 +5,6 @@ import java.io.File
import java.net.URI import java.net.URI
import java.net.URLEncoder import java.net.URLEncoder
// TEMP DEBUG (see task: alias multi-word arguments investigation) - remove once confirmed
// fixed. Traces the full alias pipeline: raw input -> matched alias -> substituted target
// -> the actual Desktop API call, so a silent failure anywhere in that chain is visible.
private fun aliasDebug(msg: String) {
println("[alias-debug] $msg")
}
// Name reserved for the app-level /reload command (ChannelWindow.kt handles it directly, // Name reserved for the app-level /reload command (ChannelWindow.kt handles it directly,
// before alias lookup even runs) - kept here too so a line in aliases.txt can never define // before alias lookup even runs) - kept here too so a line in aliases.txt can never define
// an alias that would shadow it. // an alias that would shadow it.
@@ -64,15 +57,9 @@ object AliasRepository {
* "reload" name, which this repository never loads as an alias). * "reload" name, which this repository never loads as an alias).
*/ */
fun tryRunAlias(command: String, args: String): Boolean { fun tryRunAlias(command: String, args: String): Boolean {
aliasDebug("tryRunAlias: command=\"$command\" args=\"$args\"")
val key = command.lowercase() val key = command.lowercase()
if (key == RESERVED_RELOAD) return false if (key == RESERVED_RELOAD) return false
val action = aliases[key] val action = aliases[key] ?: return false
if (action == null) {
aliasDebug("no alias matched for \"$key\"")
return false
}
aliasDebug("matched \"$key\" -> \"$action\"")
runAction(action, args) runAction(action, args)
return true return true
} }
@@ -94,13 +81,12 @@ object AliasRepository {
// literal, not a URL delimiter. // literal, not a URL delimiter.
val substitutedArg = if (type == "url") encodeArgForUrl(args) else args val substitutedArg = if (type == "url") encodeArgForUrl(args) else args
val target = if (rawTarget.contains("{arg}")) rawTarget.replace("{arg}", substitutedArg) else rawTarget val target = if (rawTarget.contains("{arg}")) rawTarget.replace("{arg}", substitutedArg) else rawTarget
aliasDebug("type=\"$type\" rawTarget=\"$rawTarget\" substitutedArg=\"$substitutedArg\" -> target=\"$target\"")
if (target.isBlank()) return if (target.isBlank()) return
try { try {
when (type) { when (type) {
"path" -> { aliasDebug("Desktop.open(File(\"$target\"))"); Desktop.getDesktop().open(File(target)) } "path" -> Desktop.getDesktop().open(File(target))
"url" -> { aliasDebug("Desktop.browse(URI(\"$target\"))"); Desktop.getDesktop().browse(URI(target)) } "url" -> Desktop.getDesktop().browse(URI(target))
// Routed through "cmd /c start" (same launch path as double-clicking in // Routed through "cmd /c start" (same launch path as double-clicking in
// Explorer or the Run dialog) rather than exec'd directly. A direct // Explorer or the Run dialog) rather than exec'd directly. A direct
// ProcessBuilder(target).start() calls CreateProcess, which does NOT honor // ProcessBuilder(target).start() calls CreateProcess, which does NOT honor
@@ -110,7 +96,7 @@ object AliasRepository {
// would. This also preserves bare-name resolution via Windows' App Paths // would. This also preserves bare-name resolution via Windows' App Paths
// registry / PATH - not guaranteed to work for every app, since it depends // registry / PATH - not guaranteed to work for every app, since it depends
// on that app having registered itself with Windows. // on that app having registered itself with Windows.
"run" -> { aliasDebug("ProcessBuilder start \"$target\""); ProcessBuilder("cmd", "/c", "start", "\"\"", target).start() } "run" -> ProcessBuilder("cmd", "/c", "start", "\"\"", target).start()
else -> println("[AliasRepository] Unknown alias action type '$type' in \"$action\"") else -> println("[AliasRepository] Unknown alias action type '$type' in \"$action\"")
} }
} catch (e: Exception) { } catch (e: Exception) {
+29
View File
@@ -47,6 +47,35 @@ data class IrcMessage(
/** Buffer key: server id + channel/query name (channel names include '#'). */ /** Buffer key: server id + channel/query name (channel names include '#'). */
data class BufferKey(val serverId: String, val name: String) data class BufferKey(val serverId: String, val name: String)
/**
* wIRC main's own buffer: server messages (MOTD, numerics, errors, whois fallback) and
* services/server notices from every connected server. 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. Also used as the
* window_prefs.json key ("wirc-main__main") for the main window's saved position and size.
*/
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('!'))
}
data class ChannelUser( data class ChannelUser(
val nick: String, val nick: String,
val isOp: Boolean = false, val isOp: Boolean = false,
-6
View File
@@ -42,16 +42,10 @@ class ServerRepository(rootDir: File) {
} }
fun upsert(server: ServerConfig) { fun upsert(server: ServerConfig) {
// TEMP DEBUG (see task: on-connect identify command investigation) - confirms the
// Edit Server dialog's Save actually persists onConnectCommands, by writing then
// immediately re-reading the file back rather than trusting the in-memory value.
println("[ServerRepository] upsert(${server.id}): saving onConnectCommands=${server.onConnectCommands}")
val current = loadAll().toMutableList() val current = loadAll().toMutableList()
val idx = current.indexOfFirst { it.id == server.id } val idx = current.indexOfFirst { it.id == server.id }
if (idx >= 0) current[idx] = server else current.add(server) if (idx >= 0) current[idx] = server else current.add(server)
saveAll(current) saveAll(current)
val reloaded = loadAll().firstOrNull { it.id == server.id }
println("[ServerRepository] upsert(${server.id}): reloaded from disk onConnectCommands=${reloaded?.onConnectCommands}")
} }
fun remove(serverId: String) { fun remove(serverId: String) {
+80 -18
View File
@@ -5,6 +5,9 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import org.json.JSONObject import org.json.JSONObject
import java.io.File import java.io.File
import java.nio.file.AtomicMoveNotSupportedException
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
data class WindowDisplaySettings( data class WindowDisplaySettings(
@@ -17,19 +20,41 @@ data class WindowDisplaySettings(
val showImagePreviews: Boolean = false // opt-in: auto-loading images reveals your IP to the host val showImagePreviews: Boolean = false // opt-in: auto-loading images reveals your IP to the host
) )
/**
* A window's last floating position and size, in dp (which Compose Desktop maps 1:1 to
* AWT's logical screen coordinates). Every field is optional in window_prefs.json, so files
* written before this existed still load - a window with no saved bounds just gets a
* cascaded default (see ui/WindowPlacement.kt).
*/
data class WindowBounds(
val x: Float?,
val y: Float?,
val width: Float?,
val height: Float?
)
/** /**
* Desktop equivalent of the Android DataStore-backed repository. Settings for every * Desktop equivalent of the Android DataStore-backed repository. Settings for every
* buffer are kept as one JSON file (rootDir/window_prefs.json), keyed by buffer storage * buffer are kept as one JSON file (rootDir/window_prefs.json), keyed by buffer storage
* id, loaded once at startup and rewritten on every change. Each buffer's settings are * id, loaded once at startup and rewritten on every change. Each buffer's settings are
* exposed as a StateFlow so every open window for that buffer updates live, same as * exposed as a StateFlow so every open window for that buffer updates live, same as
* DataStore's Flow did on Android. * DataStore's Flow did on Android.
*
* Window bounds live in the same JSON object per key (windowX/windowY/windowWidth/
* windowHeight) but are kept out of the display-settings StateFlow, so moving or resizing
* a window doesn't recompose its content.
*/ */
class WindowPrefsRepository(rootDir: File) { class WindowPrefsRepository(rootDir: File) {
private val file = File(rootDir, "window_prefs.json") private val file = File(rootDir, "window_prefs.json")
private val lock = Any() private val lock = Any()
private val flows = ConcurrentHashMap<String, MutableStateFlow<WindowDisplaySettings>>() private val flows = ConcurrentHashMap<String, MutableStateFlow<WindowDisplaySettings>>()
private var stored: MutableMap<String, WindowDisplaySettings> = loadAll() private val stored = mutableMapOf<String, WindowDisplaySettings>()
private val storedBounds = mutableMapOf<String, WindowBounds>()
init {
loadAll()
}
fun settingsFor(bufferKeyId: String): StateFlow<WindowDisplaySettings> = flowFor(bufferKeyId).asStateFlow() fun settingsFor(bufferKeyId: String): StateFlow<WindowDisplaySettings> = flowFor(bufferKeyId).asStateFlow()
@@ -47,8 +72,18 @@ class WindowPrefsRepository(rootDir: File) {
fun setShowImagePreviews(bufferKeyId: String, show: Boolean) = update(bufferKeyId) { it.copy(showImagePreviews = show) } fun setShowImagePreviews(bufferKeyId: String, show: Boolean) = update(bufferKeyId) { it.copy(showImagePreviews = show) }
fun boundsFor(bufferKeyId: String): WindowBounds? = synchronized(lock) { storedBounds[bufferKeyId] }
fun setWindowBounds(bufferKeyId: String, bounds: WindowBounds) {
synchronized(lock) {
if (storedBounds[bufferKeyId] == bounds) return
storedBounds[bufferKeyId] = bounds
persist()
}
}
private fun flowFor(bufferKeyId: String): MutableStateFlow<WindowDisplaySettings> = private fun flowFor(bufferKeyId: String): MutableStateFlow<WindowDisplaySettings> =
flows.getOrPut(bufferKeyId) { MutableStateFlow(stored[bufferKeyId] ?: WindowDisplaySettings()) } flows.getOrPut(bufferKeyId) { MutableStateFlow(synchronized(lock) { stored[bufferKeyId] } ?: WindowDisplaySettings()) }
private fun update(bufferKeyId: String, transform: (WindowDisplaySettings) -> WindowDisplaySettings) { private fun update(bufferKeyId: String, transform: (WindowDisplaySettings) -> WindowDisplaySettings) {
synchronized(lock) { synchronized(lock) {
@@ -59,14 +94,16 @@ class WindowPrefsRepository(rootDir: File) {
} }
} }
private fun loadAll(): MutableMap<String, WindowDisplaySettings> { private fun JSONObject.optFloatOrNull(key: String): Float? =
if (!file.exists()) return mutableMapOf() if (has(key) && !isNull(key)) optDouble(key).toFloat().takeIf { !it.isNaN() } else null
return try {
private fun loadAll() {
if (!file.exists()) return
try {
val obj = JSONObject(file.readText()) val obj = JSONObject(file.readText())
val result = mutableMapOf<String, WindowDisplaySettings>()
obj.keys().forEach { key -> obj.keys().forEach { key ->
val o = obj.getJSONObject(key) val o = obj.getJSONObject(key)
result[key] = WindowDisplaySettings( stored[key] = WindowDisplaySettings(
backgroundAlpha = o.optDouble("backgroundAlpha", 0.85).toFloat(), backgroundAlpha = o.optDouble("backgroundAlpha", 0.85).toFloat(),
textColorMircIndex = o.optInt("textColorMircIndex", 0), textColorMircIndex = o.optInt("textColorMircIndex", 0),
fontSize = o.optDouble("fontSize", 14.0).toFloat(), fontSize = o.optDouble("fontSize", 14.0).toFloat(),
@@ -75,28 +112,53 @@ class WindowPrefsRepository(rootDir: File) {
nickColorsEnabled = o.optBoolean("nickColorsEnabled", true), nickColorsEnabled = o.optBoolean("nickColorsEnabled", true),
showImagePreviews = o.optBoolean("showImagePreviews", false) showImagePreviews = o.optBoolean("showImagePreviews", false)
) )
val bounds = WindowBounds(
x = o.optFloatOrNull("windowX"),
y = o.optFloatOrNull("windowY"),
width = o.optFloatOrNull("windowWidth"),
height = o.optFloatOrNull("windowHeight")
)
if (bounds != WindowBounds(null, null, null, null)) storedBounds[key] = bounds
} }
result
} catch (e: Exception) { } catch (e: Exception) {
mutableMapOf() stored.clear()
storedBounds.clear()
} }
} }
// Written to a temp file and moved into place, same as MessageHistoryRepository - this
// file is now rewritten every time a window is moved or resized, so a crash mid-write
// must not be able to leave a truncated file (which loadAll would read as "no settings
// at all" and the next save would make permanent).
private fun persist() { private fun persist() {
val obj = JSONObject() val obj = JSONObject()
stored.forEach { (key, settings) -> (stored.keys + storedBounds.keys).forEach { key ->
obj.put(key, JSONObject().apply { obj.put(key, JSONObject().apply {
put("backgroundAlpha", settings.backgroundAlpha) stored[key]?.let { settings ->
put("textColorMircIndex", settings.textColorMircIndex) put("backgroundAlpha", settings.backgroundAlpha)
put("fontSize", settings.fontSize) put("textColorMircIndex", settings.textColorMircIndex)
put("showUserList", settings.showUserList) put("fontSize", settings.fontSize)
put("mentionHighlightColorArgb", settings.mentionHighlightColorArgb) put("showUserList", settings.showUserList)
put("nickColorsEnabled", settings.nickColorsEnabled) put("mentionHighlightColorArgb", settings.mentionHighlightColorArgb)
put("showImagePreviews", settings.showImagePreviews) put("nickColorsEnabled", settings.nickColorsEnabled)
put("showImagePreviews", settings.showImagePreviews)
}
storedBounds[key]?.let { bounds ->
bounds.x?.let { put("windowX", it) }
bounds.y?.let { put("windowY", it) }
bounds.width?.let { put("windowWidth", it) }
bounds.height?.let { put("windowHeight", it) }
}
}) })
} }
file.parentFile?.mkdirs() file.parentFile?.mkdirs()
file.writeText(obj.toString()) val tmp = File(file.parentFile, "${file.name}.tmp")
tmp.writeText(obj.toString())
try {
Files.move(tmp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE)
} catch (e: AtomicMoveNotSupportedException) {
Files.move(tmp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING)
}
} }
} }
+193 -47
View File
@@ -14,6 +14,9 @@ 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.io.File import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
// Per-buffer message history cap. Named so it's a one-line change to temporarily lower // Per-buffer message history cap. Named so it's a one-line change to temporarily lower
@@ -28,6 +31,19 @@ private const val MESSAGE_HISTORY_CAP = 500
// reported slowness under real message volume. // reported slowness under real message volume.
private const val SAVE_DEBOUNCE_MS = 2500L private const val SAVE_DEBOUNCE_MS = 2500L
// WHOIS reply numerics. Each one's params are [ourNick, targetNick, ...], so the target
// nick (params[1]) is what ties a reply line back to the /whois that asked for it.
// 318 is RPL_ENDOFWHOIS; 401/402 are the "no such nick/server" errors a WHOIS can get.
private val WHOIS_NUMERICS = setOf(
"275", "276", "301", "307", "310", "311", "312", "313", "317", "318", "319",
"320", "330", "335", "338", "378", "379", "401", "402", "671"
)
// Numerics that only make sense inside a channel window and are already reflected there
// (353 fills the user list, see handleLine) or carry nothing worth showing on their own -
// kept out of wIRC main so joining a channel doesn't flood it.
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 read from the same source of truth via StateFlow. * windows read from the same source of truth via StateFlow.
@@ -47,8 +63,19 @@ 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
// Buffers that currently have a window open, kept up to date by Main.kt. Read when a
// WHOIS reply arrives, to decide whether the window that asked is still there to show it.
@Volatile
var openWindows: Set<BufferKey> = emptySet()
// serverId -> (lowercased target nick -> the buffer whose window typed the /whois).
private val pendingWhois = ConcurrentHashMap<String, ConcurrentHashMap<String, BufferKey>>()
fun init(rootDir: File) { fun init(rootDir: File) {
historyRepo = MessageHistoryRepository(rootDir) historyRepo = MessageHistoryRepository(rootDir)
// 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) {
@@ -56,11 +83,12 @@ 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 for every private message to or from a real user (services and the server go
// who wasn't already an open window). Main.kt listens to this to auto-open a // to wIRC main instead). Main.kt adds the key to its set of open windows, which opens
// window for it, same as clicking a channel manually. // the query window if it isn't open - including reopening one the user closed - and
private val _newQueryBuffers = MutableSharedFlow<BufferKey>(extraBufferCapacity = 16) // is a no-op if it already is.
val newQueryBuffers: SharedFlow<BufferKey> = _newQueryBuffers private val _queryWindowRequests = MutableSharedFlow<BufferKey>(extraBufferCapacity = 64)
val queryWindowRequests: SharedFlow<BufferKey> = _queryWindowRequests
// Every buffer state change - incoming lines, local echoes of our own sends - goes // Every buffer state change - incoming lines, local echoes of our own sends - goes
// through this single FIFO queue, processed by one consumer coroutine. Previously each // through this single FIFO queue, processed by one consumer coroutine. Previously each
@@ -92,10 +120,6 @@ object ConnectionManager {
} }
fun connect(config: ServerConfig) { fun connect(config: ServerConfig) {
// TEMP DEBUG (see task: on-connect identify command investigation) - confirms
// whether the config this connect() call actually received has the on-connect
// commands the caller thinks it saved.
println("[connmgr-debug] connect(${config.id}) called - onConnectCommands=${config.onConnectCommands}")
val existing = connections[config.id] val existing = connections[config.id]
if (existing != null) { if (existing != null) {
// This used to just `return` here, making connect() a permanent no-op for the // This used to just `return` here, making connect() a permanent no-op for the
@@ -105,7 +129,6 @@ object ConnectionManager {
// `config` was captured once at construction and never replaced. Tearing down // `config` was captured once at construction and never replaced. Tearing down
// the old connection and replacing it means a connect() call always actually // the old connection and replacing it means a connect() call always actually
// uses whatever was most recently saved. // uses whatever was most recently saved.
println("[connmgr-debug] connect(${config.id}) - already connected, replacing with a fresh connection using the latest config")
existing.disconnect() existing.disconnect()
} }
setOwnNick(config.id, config.nick) setOwnNick(config.id, config.nick)
@@ -113,7 +136,11 @@ object ConnectionManager {
config = config, config = config,
scope = scope, scope = scope,
onLine = { line -> enqueue { 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()
@@ -133,20 +160,32 @@ 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
val key = BufferKey(serverId, target)
val msg = IrcMessage(System.currentTimeMillis(), MessageKind.CHAT, myNick, text) val msg = IrcMessage(System.currentTimeMillis(), MessageKind.CHAT, myNick, text)
enqueue { addMessage(key, msg) } enqueue { addOutgoing(serverId, target, msg, "-> *$target* $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
val key = BufferKey(serverId, target)
val msg = IrcMessage(System.currentTimeMillis(), MessageKind.ACTION, myNick, action) val msg = IrcMessage(System.currentTimeMillis(), MessageKind.ACTION, myNick, action)
enqueue { addMessage(key, msg) } 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 - opening its window - for a real user.
private fun addOutgoing(serverId: String, target: String, msg: IrcMessage, mainText: String) {
when {
isChannelName(target) -> addMessage(BufferKey(serverId, target), msg)
isServiceNick(target) -> addMainLine(MessageKind.SYSTEM, null, mainText)
else -> {
val key = BufferKey(serverId, target)
addMessage(key, msg)
_queryWindowRequests.tryEmit(key)
}
}
} }
// Raw passthrough for slash commands that don't have dedicated handling. // Raw passthrough for slash commands that don't have dedicated handling.
@@ -154,6 +193,22 @@ object ConnectionManager {
connectionFor(serverId)?.send(raw) connectionFor(serverId)?.send(raw)
} }
/**
* Sends WHOIS and remembers which window asked, so the reply lines are shown in that
* window - or in wIRC main if it has been closed by the time they arrive. [args] is
* whatever followed "/whois"; its last word is the nick being looked up (the
* "/whois server nick" form puts the nick last too).
*/
fun whois(serverId: String, args: String, origin: BufferKey) {
val nick = args.trim().split(Regex("\\s+")).lastOrNull()?.takeIf { it.isNotEmpty() }
if (nick == null) {
addSystemMessage(serverId, origin.name, "Usage: /whois <nick>")
return
}
pendingWhois.getOrPut(serverId) { ConcurrentHashMap() }[nick.lowercase()] = origin
sendRaw(serverId, "WHOIS ${args.trim()}")
}
// Local-only feedback (e.g. the /reload confirmation) that never touches the // Local-only feedback (e.g. the /reload confirmation) that never touches the
// wire - just appended to the buffer the same way any other SYSTEM line is. // wire - just appended to the buffer the same way any other SYSTEM line is.
fun addSystemMessage(serverId: String, target: String, text: String) { fun addSystemMessage(serverId: String, target: String, text: String) {
@@ -177,12 +232,6 @@ object ConnectionManager {
if (_buffers.value.containsKey(key)) return if (_buffers.value.containsKey(key)) return
val seeded = historyRepo?.loadMessages(key) ?: emptyList() val seeded = historyRepo?.loadMessages(key) ?: emptyList()
_buffers.update { map -> map + (key to ChannelBuffer(key, messages = seeded)) } _buffers.update { map -> map + (key to ChannelBuffer(key, messages = seeded)) }
if (!isChannelName(key.name)) {
// tryEmit, not emit: this runs on the single, non-suspend bufferOps consumer,
// not its own coroutine, so there's nothing to suspend into. The SharedFlow's
// 16-slot extraBufferCapacity means this can't actually fail in practice.
_newQueryBuffers.tryEmit(key)
}
} }
private fun addMessage(key: BufferKey, msg: IrcMessage) { private fun addMessage(key: BufferKey, msg: IrcMessage) {
@@ -195,6 +244,10 @@ object ConnectionManager {
scheduleSave(key) scheduleSave(key)
} }
private fun addMainLine(kind: MessageKind, sender: String?, text: String) {
addMessage(MAIN_BUFFER_KEY, IrcMessage(System.currentTimeMillis(), kind, sender, text))
}
// Coalesces rapid-fire saves for the same buffer into at most one write every // Coalesces rapid-fire saves for the same buffer into at most one write every
// SAVE_DEBOUNCE_MS, instead of a full history save (up to 500 messages) on every single // SAVE_DEBOUNCE_MS, instead of a full history save (up to 500 messages) on every single
// incoming message - the direct cause of reported slowness in a busy channel. Reads // incoming message - the direct cause of reported slowness in a busy channel. Reads
@@ -229,6 +282,57 @@ object ConnectionManager {
} }
} }
// A WHOIS reply line goes to the window that typed the /whois if it's still open,
// otherwise (closed since, or a WHOIS nobody asked for here) to wIRC main.
private fun handleWhoisLine(serverId: String, line: ParsedLine) {
val target = line.params.getOrNull(1) ?: return
val pending = pendingWhois[serverId]
val origin = pending?.get(target.lowercase())
if (line.command == "318") pending?.remove(target.lowercase())
val text = "[whois] " + formatWhoisLine(line)
val msg = IrcMessage(System.currentTimeMillis(), MessageKind.SYSTEM, null, text)
if (origin != null && origin in openWindows) addMessage(origin, msg) else addMessage(MAIN_BUFFER_KEY, msg)
}
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
@@ -237,16 +341,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("ACTION") && text.endsWith("") val isCtcp = text.length > 1 && text.startsWith("\u0001") && text.endsWith("\u0001")
val body = if (isAction) text.removePrefix("ACTION ").removeSuffix("") else text val isAction = isCtcp && text.startsWith("\u0001ACTION")
// Channel messages are keyed by the channel name. Private messages arrive val body = if (isAction) text.removePrefix("\u0001ACTION ").removeSuffix("\u0001") else text
// with target == our own nick, so key those by the *sender* instead - if (isChannelName(target)) {
// that's the conversation identity a query window should use. addMessage(
val bufferName = if (isChannelName(target)) target else (nick ?: target) BufferKey(serverId, target),
addMessage( IrcMessage(now, if (isAction) MessageKind.ACTION else MessageKind.CHAT, nick, body)
BufferKey(serverId, bufferName), )
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.
// (VERSION is already answered and swallowed in IrcConnection.)
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 - and (re)open that window.
val key = BufferKey(serverId, nick ?: target)
addMessage(key, IrcMessage(now, if (isAction) MessageKind.ACTION else MessageKind.CHAT, nick, body))
_queryWindowRequests.tryEmit(key)
}
}
"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 (identify success/failure etc.) and server
// notices - wIRC main. The sender goes into the text because the
// NOTICE row layout doesn't show a nick column.
addMainLine(MessageKind.NOTICE, nick, "-${nick ?: "server"}- $text")
} else {
// A notice directed AT us arrives with target == our own nick, so key
// it by the sender, same as a private message.
val key = BufferKey(serverId, nick ?: target)
addMessage(key, IrcMessage(now, MessageKind.NOTICE, nick, text))
_queryWindowRequests.tryEmit(key)
}
} }
"JOIN" -> { "JOIN" -> {
val channel = line.params.getOrNull(0) ?: return val channel = line.params.getOrNull(0) ?: return
@@ -270,9 +404,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())
@@ -315,20 +453,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
// Same fix PRIVMSG already had: a notice directed AT us arrives with
// target == our own nick, not the sender. This mattered a lot in practice -
// NickServ/ChanServ almost universally reply to commands (IDENTIFY, etc.)
// via NOTICE rather than PRIVMSG, so without this fallback every identify
// success/failure notice was being filed under a buffer keyed by our OWN
// nick - which no window ever opens for - instead of under "NickServ",
// making it look like the automatic identify produced no feedback at all,
// whether or not it actually worked.
val bufferName = if (isChannelName(target)) target else (nick ?: target)
addMessage(BufferKey(serverId, bufferName), 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
@@ -347,6 +471,7 @@ 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" -> {
@@ -360,6 +485,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(" ")}")
}
"PONG", "CAP" -> Unit
else -> {
when {
line.command in WHOIS_NUMERICS -> 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())
}
}
} }
} }
} }
+302 -290
View File
@@ -56,6 +56,7 @@ import androidx.compose.ui.window.WindowState
import androidx.compose.ui.window.rememberDialogState import androidx.compose.ui.window.rememberDialogState
import data.ChannelBuffer import data.ChannelBuffer
import data.ChannelUser import data.ChannelUser
import data.IrcMessage
import data.MessageKind import data.MessageKind
import data.WindowDisplaySettings import data.WindowDisplaySettings
import irc.extractImageUrls import irc.extractImageUrls
@@ -287,108 +288,6 @@ fun WindowScope.ChannelScreen(
val textColor = mircPalette[displaySettings.textColorMircIndex] val textColor = mircPalette[displaySettings.textColorMircIndex]
val bgColor = Color.Black.copy(alpha = displaySettings.backgroundAlpha) val bgColor = Color.Black.copy(alpha = displaySettings.backgroundAlpha)
val listState = rememberLazyListState()
val timeFormat = remember { SimpleDateFormat("h:mm", Locale.getDefault()) }
// Single source of truth for "what messages exist" - used both by the scroll-tracking
// effects below and by the LazyColumn's items() call further down, so the two can never
// disagree about item count (they're reading the literal same list, not two separately
// re-derived ones).
val messages = buffer?.messages ?: emptyList()
// Standard "stick to bottom unless the user scrolled up" chat-client pattern - the
// ONLY code in this file that touches scroll position. A previous version of this had
// several LaunchedEffects independently reading and writing listState/stickToBottom
// (a resize-driven re-snap effect, a separate "first scroll" flag, extra debug-only
// effects) - multiple effects fighting over the same LazyListState was the suspected
// cause of the auto-scroll bug surviving several previous fix attempts, so this is
// deliberately reduced to exactly two effects and nothing else moves listState.
var stickToBottom by remember { mutableStateOf(true) }
// The LazyColumn below uses reverseLayout=true with this reversed (newest-first) list,
// not the previous approach of a normal-order list plus scrollToItem(lastIndex) to jump
// to the end. That combination has a real, well-documented failure mode this app kept
// hitting: scrollToItem(lastIndex) asks for the LAST item to become the FIRST visible
// item (positioned at the top of the viewport) - there's nothing after the last item to
// fill the rest of the viewport, so whether that visually ends up "correct" (Compose
// pulling earlier items in above it to avoid a trailing gap) or "one item short" depends
// on exactly how much prior content exists and how the scroll/layout timing lines up
// under rapid successive calls - which matches the reported symptom (a channel with
// little content, and - independently - a fast-moving one, both showing the newest
// message perpetually one behind). reverseLayout flips which edge that ambiguity can
// ever land on: index 0 (the newest message) is always anchored flush against the
// viewport's bottom edge, and any shortage of content shows up as blank space ABOVE the
// (sparse) history instead of hiding the newest line - the standard, well-established
// pattern real Compose chat apps use for exactly this reason.
val reversedMessages = messages.asReversed()
// How close to the very bottom (index 0, offset 0) counts as "still at the bottom" when
// a scroll comes to rest - a little slack so a pixel or two of residual offset doesn't
// read as "scrolled away".
val stickToBottomThresholdPx = with(LocalDensity.current) { 24.dp.toPx() }
// Track whether the user is currently near the bottom (= near index 0, since the list
// is reversed) of the scrollback. If they've manually scrolled up to read backscroll,
// stop auto-scrolling until they scroll back down themselves.
//
// This only recomputes stickToBottom while isScrollInProgress is true, plus the single
// emission right after a scroll just ended (tracked via wasScrolling), so that scroll's
// final resting position still gets recorded. Previously this derived stickToBottom from
// firstVisibleItemIndex alone, which also changes from pure item-anchoring: reverseLayout
// anchors the previously-first-visible item by key when a new item is inserted at index 0,
// so every new message bumped that index by one with zero actual scrolling - two or more
// messages landing before this effect re-ran pushed the index past the old "<= 1" cutoff
// and flipped stickToBottom off for the rest of the session, silently disabling auto-scroll
// during any message burst.
LaunchedEffect(listState) {
var wasScrolling = false
snapshotFlow {
Triple(
listState.isScrollInProgress,
listState.firstVisibleItemIndex,
listState.firstVisibleItemScrollOffset
)
}.collect { (isScrolling, index, offset) ->
if (isScrolling || wasScrolling) {
stickToBottom = index == 0 && offset <= stickToBottomThresholdPx
}
wasScrolling = isScrolling
}
}
// Whenever a new message actually lands, jump to index 0 (the newest, since the list is
// reversed) IF we were already sticking to the bottom.
//
// The trigger is msg.seq, not totalItemsCount: msg.seq is monotonically increasing and
// never repeats or saturates, while ConnectionManager caps every buffer at
// MESSAGE_HISTORY_CAP (500) messages, evicting the oldest one for every new one once a
// channel hits that cap - so totalItemsCount stops changing forever past that point, and
// an effect keyed on it would silently stop firing for any channel busy enough to fill its
// scrollback.
//
// The actual scrollToItem(0) call still has to wait for the LazyColumn's own layout pass
// to genuinely catch up to that new item - calling it immediately resolves against
// whatever layout still thinks exists, which can be one short, with nothing to
// re-correct it afterwards. Waiting for `totalItemsCount >= expectedItemCount` (an earlier
// version of this wait) is not cap-proof either: once a buffer sits at the 500 cap,
// totalItemsCount was already 500 before the new item got laid out (an old item was
// evicted for it), so that wait is satisfied immediately against stale layout and
// scrollToItem(0) can still land one message short. Instead, this captures the current
// reversedMessages list at launch and waits for the layout to actually reflect it: either
// there are no visible items yet, or the first visible item's key matches what that
// captured list says should be at that index - which can only be true once layout has
// processed up through at least that item.
val newestSeq = messages.lastOrNull()?.seq
LaunchedEffect(newestSeq) {
if (newestSeq != null && stickToBottom && !listState.isScrollInProgress) {
val capturedList = reversedMessages
snapshotFlow { listState.layoutInfo.visibleItemsInfo.firstOrNull() }
.first { item ->
item == null || (item.index < capturedList.size && item.key == capturedList[item.index].seq)
}
listState.scrollToItem(0)
}
}
Box( Box(
modifier = Modifier modifier = Modifier
@@ -483,194 +382,13 @@ fun WindowScope.ChannelScreen(
// Scrollback + user list, side by side, filling the available height // Scrollback + user list, side by side, filling the available height
Row(modifier = Modifier.weight(1f).fillMaxWidth()) { Row(modifier = Modifier.weight(1f).fillMaxWidth()) {
// Scrollback height is snapped down to a whole multiple of one line's height so a MessageScrollback(
// resize never leaves a partially-rendered line visible - the leftover fractional messages = buffer?.messages ?: emptyList(),
// space is absorbed by the Spacer below instead, leaving the rest of the layout displaySettings = displaySettings,
// (nicklist, input bar) unaffected. textColor = textColor,
BoxWithConstraints( isMention = { isMention(it) },
modifier = Modifier modifier = Modifier.weight(1f).fillMaxHeight()
.weight(1f) )
.fillMaxHeight()
) {
// Investigated (see task: resized-window slowdown) whether this was
// re-measuring text or producing an unstable snappedHeight on every
// recomposition instead of only on a genuine font-size/window-size
// change - confirmed via real recomposition/measurement counters
// (temporarily added, since removed) across a side-by-side real-window
// test that it was not: TextMeasurer.measure() ran exactly once per
// window for the whole test (remember(displaySettings.fontSize) is
// correctly gating it), snappedHeight only changed on an actual resize
// and stayed byte-for-byte stable between messages otherwise, and
// recomposition volume was ~2.5% apart between a resized and an
// untouched window under identical message load - not the source of
// the reported slowdown.
val density = LocalDensity.current
val textMeasurer = rememberTextMeasurer()
// Keyed on density (screen DPI/font scale), not just fontSize - the
// measured value is a raw pixel height for the CURRENT density, and
// without density in the key this stayed cached at whatever density was
// in effect the first time this window measured a line, even after the
// window moved to a monitor with different scaling. lineHeightDp below
// then converts that now-stale pixel value using the NEW density, so the
// two silently disagreed until the window (and this remember scope) was
// recreated by closing and reopening it.
val lineHeightPx = remember(displaySettings.fontSize, density) {
textMeasurer.measure(
text = "Mg",
style = androidx.compose.ui.text.TextStyle(
fontFamily = FontFamily.Monospace,
fontSize = displaySettings.fontSize.sp
)
).size.height
}
val lineHeightDp = with(density) { lineHeightPx.toDp() }
// Ruled out (see task: newest-message-missing investigation) as the
// cause of the newest message being cut off - confirmed via a real,
// visual, screenshot-verified test that bypassing this snap entirely
// (using maxHeight raw) did not fix the missing-newest-message bug. The
// real cause was a race between the scroll effect and the LazyColumn's
// own layout catching up to new items - see the scroll effects below.
val snappedHeight = if (lineHeightDp > 0.dp) {
lineHeightDp * floor(maxHeight / lineHeightDp).toInt()
} else {
maxHeight
}
// NOTE: this used to also re-snap the scroll *offset* to the first
// visible item on every resize/font-size change, to fix a partial line
// stuck at the TOP of the viewport (as opposed to the bottom, which
// snappedHeight above already handles). Removed - stickToBottom above is
// now the only thing that's allowed to touch listState, since multiple
// effects independently reading/writing it was the suspected cause of
// the auto-scroll bug surviving several previous fix attempts. If a
// top-cut-off-line-on-resize regression shows up again, it needs a fix
// that doesn't call scrollToItem itself - e.g. reporting the needed
// offset correction through stickToBottom's own effect instead of a
// second independent one.
Column(modifier = Modifier.fillMaxSize()) {
LazyColumn(
state = listState,
reverseLayout = true,
modifier = Modifier
.height(snappedHeight)
.padding(horizontal = 6.dp)
) {
items(
items = reversedMessages,
// msg.seq is a monotonically increasing, guaranteed-unique id (see
// IrcMessage) - the previous timestamp+sender+text-hash composite
// could collide in a busy channel (two messages same millisecond,
// same sender, same short text - not rare for "lol"/an emoji/etc),
// and Compose requires unique keys per item.
key = { msg -> msg.seq }
) { msg ->
val ts = timeFormat.format(Date(msg.timestampMillis))
val bodyAnnotated = parseMircToAnnotatedString(msg.rawText, textColor)
val mentioned = isMention(msg.rawText)
val nickColor = if (displaySettings.nickColorsEnabled) colorForNick(msg.sender ?: "") else textColor
val mutedColor = textColor.copy(alpha = 0.7f)
Column(modifier = Modifier.fillMaxWidth()) {
// Neutralizes the ambient LocalTextStyle so the plain Text() prefix/nick
// pieces don't inherit MaterialTheme.typography.bodyLarge's fixed 24sp
// lineHeight from the MaterialTheme wrapper in ChannelWindow.kt - without
// this, those Texts get a much taller line box than the ClickableText body
// below (which uses its own explicit TextStyle and never merges with the
// ambient one), and Row's default top alignment then renders the body
// visibly offset above its own prefix. The Android version never wraps
// ChannelScreen in a MaterialTheme at all, so it never hits this mismatch.
CompositionLocalProvider(LocalTextStyle provides androidx.compose.ui.text.TextStyle.Default) {
Row(
modifier = Modifier
.fillMaxWidth()
.let { if (mentioned) it.background(Color(displaySettings.mentionHighlightColorArgb)) else it }
) {
when (msg.kind) {
MessageKind.CHAT -> {
Text(
text = ts,
color = mutedColor,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
fontSize = displaySettings.fontSize.sp
)
Text(
text = " ${displayNick(msg.sender ?: "")}${NICK_COLUMN_GAP}",
color = nickColor,
fontFamily = FontFamily.Monospace,
fontSize = displaySettings.fontSize.sp
)
}
MessageKind.ACTION -> {
Text(
text = ts,
color = mutedColor,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
fontSize = displaySettings.fontSize.sp
)
Text(
text = " * ",
color = mutedColor,
fontFamily = FontFamily.Monospace,
fontSize = displaySettings.fontSize.sp
)
Text(
text = "${displayNick(msg.sender ?: "")}${NICK_COLUMN_GAP}",
color = nickColor,
fontFamily = FontFamily.Monospace,
fontSize = displaySettings.fontSize.sp
)
}
else -> {
Text(
text = ts,
color = mutedColor,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
fontSize = displaySettings.fontSize.sp
)
Text(
text = " * ",
color = mutedColor,
fontFamily = FontFamily.Monospace,
fontSize = displaySettings.fontSize.sp
)
}
}
ClickableText(
text = bodyAnnotated,
style = androidx.compose.ui.text.TextStyle(
fontFamily = FontFamily.Monospace,
fontSize = displaySettings.fontSize.sp
),
onClick = { offset ->
bodyAnnotated.getStringAnnotations(tag = "URL", start = offset, end = offset)
.firstOrNull()?.let { annotation ->
openUrlInBrowser(annotation.item)
}
}
)
}
}
if (displaySettings.showImagePreviews) {
extractImageUrls(msg.rawText).forEach { url ->
UrlImagePreview(
url = url,
modifier = Modifier
.padding(start = 8.dp, top = 2.dp, bottom = 4.dp)
.heightIn(max = 200.dp)
.clip(RoundedCornerShape(4.dp)),
onClick = { openUrlInBrowser(url) }
)
}
}
}
}
}
Spacer(modifier = Modifier.weight(1f))
}
}
// User list (tap and hold for OP actions) // User list (tap and hold for OP actions)
if (displaySettings.showUserList && !buffer?.users.isNullOrEmpty()) { if (displaySettings.showUserList && !buffer?.users.isNullOrEmpty()) {
@@ -804,6 +522,300 @@ fun WindowScope.ChannelScreen(
} }
} }
// Scrollback display shared by channel/PM windows (ChannelScreen) and wIRC main
// (MainScreen): the message rows, image previews, line-height snapping and the
// stick-to-bottom auto-scroll. Draws no background of its own - callers do.
@Composable
fun MessageScrollback(
messages: List<IrcMessage>,
displaySettings: WindowDisplaySettings,
textColor: Color,
isMention: (String) -> Boolean,
modifier: Modifier = Modifier
) {
val listState = rememberLazyListState()
val timeFormat = remember { SimpleDateFormat("h:mm", Locale.getDefault()) }
// Standard "stick to bottom unless the user scrolled up" chat-client pattern - the
// ONLY code in this file that touches scroll position. A previous version of this had
// several LaunchedEffects independently reading and writing listState/stickToBottom
// (a resize-driven re-snap effect, a separate "first scroll" flag, extra debug-only
// effects) - multiple effects fighting over the same LazyListState was the suspected
// cause of the auto-scroll bug surviving several previous fix attempts, so this is
// deliberately reduced to exactly two effects and nothing else moves listState.
var stickToBottom by remember { mutableStateOf(true) }
// The LazyColumn below uses reverseLayout=true with this reversed (newest-first) list,
// not the previous approach of a normal-order list plus scrollToItem(lastIndex) to jump
// to the end. That combination has a real, well-documented failure mode this app kept
// hitting: scrollToItem(lastIndex) asks for the LAST item to become the FIRST visible
// item (positioned at the top of the viewport) - there's nothing after the last item to
// fill the rest of the viewport, so whether that visually ends up "correct" (Compose
// pulling earlier items in above it to avoid a trailing gap) or "one item short" depends
// on exactly how much prior content exists and how the scroll/layout timing lines up
// under rapid successive calls - which matches the reported symptom (a channel with
// little content, and - independently - a fast-moving one, both showing the newest
// message perpetually one behind). reverseLayout flips which edge that ambiguity can
// ever land on: index 0 (the newest message) is always anchored flush against the
// viewport's bottom edge, and any shortage of content shows up as blank space ABOVE the
// (sparse) history instead of hiding the newest line - the standard, well-established
// pattern real Compose chat apps use for exactly this reason.
val reversedMessages = messages.asReversed()
// How close to the very bottom (index 0, offset 0) counts as "still at the bottom" when
// a scroll comes to rest - a little slack so a pixel or two of residual offset doesn't
// read as "scrolled away".
val stickToBottomThresholdPx = with(LocalDensity.current) { 24.dp.toPx() }
// Track whether the user is currently near the bottom (= near index 0, since the list
// is reversed) of the scrollback. If they've manually scrolled up to read backscroll,
// stop auto-scrolling until they scroll back down themselves.
//
// This only recomputes stickToBottom while isScrollInProgress is true, plus the single
// emission right after a scroll just ended (tracked via wasScrolling), so that scroll's
// final resting position still gets recorded. Previously this derived stickToBottom from
// firstVisibleItemIndex alone, which also changes from pure item-anchoring: reverseLayout
// anchors the previously-first-visible item by key when a new item is inserted at index 0,
// so every new message bumped that index by one with zero actual scrolling - two or more
// messages landing before this effect re-ran pushed the index past the old "<= 1" cutoff
// and flipped stickToBottom off for the rest of the session, silently disabling auto-scroll
// during any message burst.
LaunchedEffect(listState) {
var wasScrolling = false
snapshotFlow {
Triple(
listState.isScrollInProgress,
listState.firstVisibleItemIndex,
listState.firstVisibleItemScrollOffset
)
}.collect { (isScrolling, index, offset) ->
if (isScrolling || wasScrolling) {
stickToBottom = index == 0 && offset <= stickToBottomThresholdPx
}
wasScrolling = isScrolling
}
}
// Whenever a new message actually lands, jump to index 0 (the newest, since the list is
// reversed) IF we were already sticking to the bottom.
//
// The trigger is msg.seq, not totalItemsCount: msg.seq is monotonically increasing and
// never repeats or saturates, while ConnectionManager caps every buffer at
// MESSAGE_HISTORY_CAP (500) messages, evicting the oldest one for every new one once a
// channel hits that cap - so totalItemsCount stops changing forever past that point, and
// an effect keyed on it would silently stop firing for any channel busy enough to fill its
// scrollback.
//
// The actual scrollToItem(0) call still has to wait for the LazyColumn's own layout pass
// to genuinely catch up to that new item - calling it immediately resolves against
// whatever layout still thinks exists, which can be one short, with nothing to
// re-correct it afterwards. Waiting for `totalItemsCount >= expectedItemCount` (an earlier
// version of this wait) is not cap-proof either: once a buffer sits at the 500 cap,
// totalItemsCount was already 500 before the new item got laid out (an old item was
// evicted for it), so that wait is satisfied immediately against stale layout and
// scrollToItem(0) can still land one message short. Instead, this captures the current
// reversedMessages list at launch and waits for the layout to actually reflect it: either
// there are no visible items yet, or the first visible item's key matches what that
// captured list says should be at that index - which can only be true once layout has
// processed up through at least that item.
val newestSeq = messages.lastOrNull()?.seq
LaunchedEffect(newestSeq) {
if (newestSeq != null && stickToBottom && !listState.isScrollInProgress) {
val capturedList = reversedMessages
snapshotFlow { listState.layoutInfo.visibleItemsInfo.firstOrNull() }
.first { item ->
item == null || (item.index < capturedList.size && item.key == capturedList[item.index].seq)
}
listState.scrollToItem(0)
}
}
// Scrollback height is snapped down to a whole multiple of one line's height so a
// resize never leaves a partially-rendered line visible - the leftover fractional
// space is absorbed by the Spacer below instead, leaving the rest of the layout
// (nicklist, input bar) unaffected.
BoxWithConstraints(modifier = modifier) {
// Investigated (see task: resized-window slowdown) whether this was
// re-measuring text or producing an unstable snappedHeight on every
// recomposition instead of only on a genuine font-size/window-size
// change - confirmed via real recomposition/measurement counters
// (temporarily added, since removed) across a side-by-side real-window
// test that it was not: TextMeasurer.measure() ran exactly once per
// window for the whole test (remember(displaySettings.fontSize) is
// correctly gating it), snappedHeight only changed on an actual resize
// and stayed byte-for-byte stable between messages otherwise, and
// recomposition volume was ~2.5% apart between a resized and an
// untouched window under identical message load - not the source of
// the reported slowdown.
val density = LocalDensity.current
val textMeasurer = rememberTextMeasurer()
// Keyed on density (screen DPI/font scale), not just fontSize - the
// measured value is a raw pixel height for the CURRENT density, and
// without density in the key this stayed cached at whatever density was
// in effect the first time this window measured a line, even after the
// window moved to a monitor with different scaling. lineHeightDp below
// then converts that now-stale pixel value using the NEW density, so the
// two silently disagreed until the window (and this remember scope) was
// recreated by closing and reopening it.
val lineHeightPx = remember(displaySettings.fontSize, density) {
textMeasurer.measure(
text = "Mg",
style = androidx.compose.ui.text.TextStyle(
fontFamily = FontFamily.Monospace,
fontSize = displaySettings.fontSize.sp
)
).size.height
}
val lineHeightDp = with(density) { lineHeightPx.toDp() }
// Ruled out (see task: newest-message-missing investigation) as the
// cause of the newest message being cut off - confirmed via a real,
// visual, screenshot-verified test that bypassing this snap entirely
// (using maxHeight raw) did not fix the missing-newest-message bug. The
// real cause was a race between the scroll effect and the LazyColumn's
// own layout catching up to new items - see the scroll effects below.
val snappedHeight = if (lineHeightDp > 0.dp) {
lineHeightDp * floor(maxHeight / lineHeightDp).toInt()
} else {
maxHeight
}
// NOTE: this used to also re-snap the scroll *offset* to the first
// visible item on every resize/font-size change, to fix a partial line
// stuck at the TOP of the viewport (as opposed to the bottom, which
// snappedHeight above already handles). Removed - stickToBottom above is
// now the only thing that's allowed to touch listState, since multiple
// effects independently reading/writing it was the suspected cause of
// the auto-scroll bug surviving several previous fix attempts. If a
// top-cut-off-line-on-resize regression shows up again, it needs a fix
// that doesn't call scrollToItem itself - e.g. reporting the needed
// offset correction through stickToBottom's own effect instead of a
// second independent one.
Column(modifier = Modifier.fillMaxSize()) {
LazyColumn(
state = listState,
reverseLayout = true,
modifier = Modifier
.height(snappedHeight)
.padding(horizontal = 6.dp)
) {
items(
items = reversedMessages,
// msg.seq is a monotonically increasing, guaranteed-unique id (see
// IrcMessage) - the previous timestamp+sender+text-hash composite
// could collide in a busy channel (two messages same millisecond,
// same sender, same short text - not rare for "lol"/an emoji/etc),
// and Compose requires unique keys per item.
key = { msg -> msg.seq }
) { msg ->
val ts = timeFormat.format(Date(msg.timestampMillis))
val bodyAnnotated = parseMircToAnnotatedString(msg.rawText, textColor)
val mentioned = isMention(msg.rawText)
val nickColor = if (displaySettings.nickColorsEnabled) colorForNick(msg.sender ?: "") else textColor
val mutedColor = textColor.copy(alpha = 0.7f)
Column(modifier = Modifier.fillMaxWidth()) {
// Neutralizes the ambient LocalTextStyle so the plain Text() prefix/nick
// pieces don't inherit MaterialTheme.typography.bodyLarge's fixed 24sp
// lineHeight from the MaterialTheme wrapper in ChannelWindow.kt - without
// this, those Texts get a much taller line box than the ClickableText body
// below (which uses its own explicit TextStyle and never merges with the
// ambient one), and Row's default top alignment then renders the body
// visibly offset above its own prefix. The Android version never wraps
// ChannelScreen in a MaterialTheme at all, so it never hits this mismatch.
CompositionLocalProvider(LocalTextStyle provides androidx.compose.ui.text.TextStyle.Default) {
Row(
modifier = Modifier
.fillMaxWidth()
.let { if (mentioned) it.background(Color(displaySettings.mentionHighlightColorArgb)) else it }
) {
when (msg.kind) {
MessageKind.CHAT -> {
Text(
text = ts,
color = mutedColor,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
fontSize = displaySettings.fontSize.sp
)
Text(
text = " ${displayNick(msg.sender ?: "")}${NICK_COLUMN_GAP}",
color = nickColor,
fontFamily = FontFamily.Monospace,
fontSize = displaySettings.fontSize.sp
)
}
MessageKind.ACTION -> {
Text(
text = ts,
color = mutedColor,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
fontSize = displaySettings.fontSize.sp
)
Text(
text = " * ",
color = mutedColor,
fontFamily = FontFamily.Monospace,
fontSize = displaySettings.fontSize.sp
)
Text(
text = "${displayNick(msg.sender ?: "")}${NICK_COLUMN_GAP}",
color = nickColor,
fontFamily = FontFamily.Monospace,
fontSize = displaySettings.fontSize.sp
)
}
else -> {
Text(
text = ts,
color = mutedColor,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
fontSize = displaySettings.fontSize.sp
)
Text(
text = " * ",
color = mutedColor,
fontFamily = FontFamily.Monospace,
fontSize = displaySettings.fontSize.sp
)
}
}
ClickableText(
text = bodyAnnotated,
style = androidx.compose.ui.text.TextStyle(
fontFamily = FontFamily.Monospace,
fontSize = displaySettings.fontSize.sp
),
onClick = { offset ->
bodyAnnotated.getStringAnnotations(tag = "URL", start = offset, end = offset)
.firstOrNull()?.let { annotation ->
openUrlInBrowser(annotation.item)
}
}
)
}
}
if (displaySettings.showImagePreviews) {
extractImageUrls(msg.rawText).forEach { url ->
UrlImagePreview(
url = url,
modifier = Modifier
.padding(start = 8.dp, top = 2.dp, bottom = 4.dp)
.heightIn(max = 200.dp)
.clip(RoundedCornerShape(4.dp)),
onClick = { openUrlInBrowser(url) }
)
}
}
}
}
}
Spacer(modifier = Modifier.weight(1f))
}
}
}
// A 28dp title-bar IconButton that rests invisible and fades in while hovered. The fade // A 28dp title-bar IconButton that rests invisible and fades in while hovered. The fade
// is a graphicsLayer alpha only - the button is always laid out, drawn and hit-tested, so // is a graphicsLayer alpha only - the button is always laid out, drawn and hit-tested, so
// it still gets hover and clicks at alpha 0. The alpha is read inside the graphicsLayer // it still gets hover and clicks at alpha 0. The alpha is read inside the graphicsLayer
+8 -2
View File
@@ -10,8 +10,9 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window import androidx.compose.ui.window.Window
import androidx.compose.ui.window.rememberWindowState
import data.AliasRepository import data.AliasRepository
import data.BufferKey import data.BufferKey
import data.WindowDisplaySettings import data.WindowDisplaySettings
@@ -59,7 +60,9 @@ fun ChannelWindow(
val buffer by remember(bufferKey) { derivedStateOf { buffers[bufferKey] } } val buffer by remember(bufferKey) { derivedStateOf { buffers[bufferKey] } }
val ownNicks by ConnectionManager.ownNicksFlow.collectAsState() val ownNicks by ConnectionManager.ownNicksFlow.collectAsState()
val ownNick by remember(serverId) { derivedStateOf { ownNicks[serverId] } } val ownNick by remember(serverId) { derivedStateOf { ownNicks[serverId] } }
val windowState = rememberWindowState() // Restored from window_prefs.json (or cascaded if there's no usable saved position)
// and saved back on move/resize/close - see WindowPlacement.kt.
val windowState = rememberPersistentWindowState(prefsRepo, bufferKey.storageId(), DpSize(800.dp, 600.dp))
// undecorated + transparent trade the native OS title bar for real per-pixel window // undecorated + transparent trade the native OS title bar for real per-pixel window
// transparency (Windows only honors transparency on undecorated windows) - ChannelScreen // transparency (Windows only honors transparency on undecorated windows) - ChannelScreen
@@ -124,6 +127,9 @@ fun ChannelWindow(
ConnectionManager.sendRaw(serverId, "JOIN $args") ConnectionManager.sendRaw(serverId, "JOIN $args")
onOpenBuffer(BufferKey(serverId, args)) onOpenBuffer(BufferKey(serverId, args))
} }
// Reply lines come back to this window, or to wIRC main if
// this window is closed before they arrive.
"WHOIS" -> ConnectionManager.whois(serverId, args, origin = bufferKey)
"MSG" -> { "MSG" -> {
val msgSpaceIdx = args.indexOf(' ') val msgSpaceIdx = args.indexOf(' ')
if (msgSpaceIdx >= 0) { if (msgSpaceIdx >= 0) {
+60 -1
View File
@@ -1,5 +1,7 @@
package ui package ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
@@ -11,7 +13,9 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.isShiftPressed import androidx.compose.ui.input.key.isShiftPressed
@@ -23,9 +27,12 @@ import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogWindow import androidx.compose.ui.window.DialogWindow
import androidx.compose.ui.window.rememberDialogState import androidx.compose.ui.window.rememberDialogState
import data.MAIN_BUFFER_KEY
import data.ServerConfig import data.ServerConfig
import data.ServerRepository import data.ServerRepository
import data.WindowDisplaySettings
import irc.ConnectionManager import irc.ConnectionManager
import irc.mircPalette
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -33,8 +40,10 @@ import kotlinx.coroutines.launch
fun MainScreen( fun MainScreen(
repo: ServerRepository, repo: ServerRepository,
colorScheme: ColorScheme, colorScheme: ColorScheme,
displaySettings: WindowDisplaySettings,
onOpenChannel: (serverId: String, channelName: String) -> Unit, onOpenChannel: (serverId: String, channelName: String) -> Unit,
onExitApp: () -> Unit onExitApp: () -> Unit,
onUnreadChange: (Int) -> Unit
) { ) {
var servers by remember { mutableStateOf(repo.loadAll()) } var servers by remember { mutableStateOf(repo.loadAll()) }
var showAddDialog by remember { mutableStateOf(false) } var showAddDialog by remember { mutableStateOf(false) }
@@ -44,6 +53,26 @@ fun MainScreen(
var openedChannels by remember { mutableStateOf(mapOf<String, List<String>>()) } var openedChannels by remember { mutableStateOf(mapOf<String, List<String>>()) }
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val buffers by ConnectionManager.buffers.collectAsState()
val mainMessages by remember { derivedStateOf { buffers[MAIN_BUFFER_KEY]?.messages ?: emptyList() } }
val textColor = mircPalette[displaySettings.textColorMircIndex]
// Unread indicator: lines that arrived in wIRC main while this window didn't have focus
// (e.g. while typing in a channel window). Shown next to the "Server messages" label and,
// via onUnreadChange, in the window title / taskbar. Cleared as soon as the window is
// focused. Compared by timestamp rather than count so history loaded at startup never
// counts as new.
val windowFocused = LocalWindowInfo.current.isWindowFocused
var lastSeenMillis by remember { mutableStateOf(System.currentTimeMillis()) }
val newest = mainMessages.lastOrNull()
LaunchedEffect(windowFocused, newest?.seq) {
if (windowFocused) {
lastSeenMillis = maxOf(lastSeenMillis, newest?.timestampMillis ?: 0L, System.currentTimeMillis())
}
}
val unread = if (windowFocused) 0 else mainMessages.count { it.timestampMillis > lastSeenMillis }
LaunchedEffect(unread) { onUnreadChange(unread) }
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -125,6 +154,34 @@ fun MainScreen(
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
} }
} }
Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Server messages", style = MaterialTheme.typography.labelMedium)
if (unread > 0) {
Spacer(Modifier.width(8.dp))
Text(
"\u25CF $unread new",
color = UNREAD_INDICATOR_COLOR,
style = MaterialTheme.typography.labelMedium
)
}
}
Spacer(Modifier.height(4.dp))
// MOTD, numerics, errors, services/server notices and WHOIS replies whose window
// is gone - see ConnectionManager. Same rendering and auto-scroll as a channel window.
MessageScrollback(
messages = mainMessages,
displaySettings = displaySettings,
textColor = textColor,
isMention = { false },
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.background(Color.Black.copy(alpha = displaySettings.backgroundAlpha))
.border(1.dp, Color.Gray.copy(alpha = 0.5f))
.padding(vertical = 2.dp)
)
} }
if (showAddDialog) { if (showAddDialog) {
@@ -141,6 +198,8 @@ fun MainScreen(
} }
} }
private val UNREAD_INDICATOR_COLOR = Color(0xFFFFA500)
@Composable @Composable
private fun ServerCard( private fun ServerCard(
server: ServerConfig, server: ServerConfig,
+158
View File
@@ -0,0 +1,158 @@
package ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isSpecified
import androidx.compose.ui.window.WindowPlacement
import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.WindowState
import androidx.compose.ui.window.rememberWindowState
import data.WindowBounds
import data.WindowPrefsRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.withContext
import java.awt.GraphicsEnvironment
import java.awt.Rectangle
import java.awt.Toolkit
// How long a window has to sit still after a move/resize before its bounds are written
// to window_prefs.json - dragging fires a position change per mouse event.
private const val BOUNDS_SAVE_DEBOUNCE_MS = 500L
// Cascade layout for windows with no usable saved position: the first one opens
// CASCADE_MARGIN_DP in from the top-left of the primary screen's usable area (taskbar
// excluded), each next one CASCADE_STEP_DP further down and right, wrapping back to the
// start before a window would run off the screen.
private const val CASCADE_MARGIN_DP = 40
private const val CASCADE_STEP_DP = 32
// A saved position only counts as "on a connected monitor" if at least this much of the
// window's top strip (where the drag handle / title bar is) lands on one screen, so a
// restored window can always be grabbed and moved.
private const val TITLE_STRIP_HEIGHT_DP = 32
private const val MIN_VISIBLE_TITLE_WIDTH_DP = 80
// Saved sizes smaller than this are treated as invalid and replaced by the default.
private const val MIN_WINDOW_WIDTH_DP = 200
private const val MIN_WINDOW_HEIGHT_DP = 120
private object WindowCascade {
private var next = 0
@Synchronized
fun nextPosition(size: DpSize): WindowPosition {
val area = primaryUsableArea()
val roomX = area.width - CASCADE_MARGIN_DP - size.width.value
val roomY = area.height - CASCADE_MARGIN_DP - size.height.value
val stepsThatFit = (minOf(roomX, roomY) / CASCADE_STEP_DP).toInt().coerceAtLeast(0)
val step = next++ % (stepsThatFit + 1)
val offset = CASCADE_MARGIN_DP + step * CASCADE_STEP_DP
return WindowPosition.Absolute((area.x + offset).dp, (area.y + offset).dp)
}
}
private fun primaryUsableArea(): Rectangle = try {
val config = GraphicsEnvironment.getLocalGraphicsEnvironment().defaultScreenDevice.defaultConfiguration
val bounds = config.bounds
val insets = Toolkit.getDefaultToolkit().getScreenInsets(config)
Rectangle(
bounds.x + insets.left,
bounds.y + insets.top,
bounds.width - insets.left - insets.right,
bounds.height - insets.top - insets.bottom
)
} catch (e: Exception) {
Rectangle(0, 0, 1280, 720)
}
private fun screenBounds(): List<Rectangle> = try {
GraphicsEnvironment.getLocalGraphicsEnvironment().screenDevices.map { it.defaultConfiguration.bounds }
} catch (e: Exception) {
emptyList()
}
private fun isOnConnectedScreen(x: Float, y: Float, width: Float): Boolean {
val strip = Rectangle(x.toInt(), y.toInt(), width.toInt().coerceAtLeast(1), TITLE_STRIP_HEIGHT_DP)
val minVisibleWidth = minOf(MIN_VISIBLE_TITLE_WIDTH_DP, strip.width)
return screenBounds().any { screen ->
val visible = screen.intersection(strip)
!visible.isEmpty && visible.width >= minVisibleWidth && visible.height >= TITLE_STRIP_HEIGHT_DP / 2
}
}
private fun resolveInitialPlacement(saved: WindowBounds?, defaultSize: DpSize): Pair<WindowPosition, DpSize> {
val savedWidth = saved?.width
val savedHeight = saved?.height
val size = if (savedWidth != null && savedHeight != null &&
savedWidth >= MIN_WINDOW_WIDTH_DP && savedHeight >= MIN_WINDOW_HEIGHT_DP
) {
// Never restore a window bigger than the largest connected screen (e.g. it was
// saved on a monitor that has since been unplugged).
val screens = screenBounds()
val maxWidth = screens.maxOfOrNull { it.width }?.toFloat() ?: savedWidth
val maxHeight = screens.maxOfOrNull { it.height }?.toFloat() ?: savedHeight
DpSize(minOf(savedWidth, maxWidth).dp, minOf(savedHeight, maxHeight).dp)
} else {
defaultSize
}
val savedX = saved?.x
val savedY = saved?.y
val position = if (savedX != null && savedY != null && isOnConnectedScreen(savedX, savedY, size.width.value)) {
WindowPosition.Absolute(savedX.dp, savedY.dp)
} else {
WindowCascade.nextPosition(size)
}
return position to size
}
private fun saveBoundsIfFloating(state: WindowState, prefsRepo: WindowPrefsRepository, storageId: String) {
// Minimized windows report a parking position far off-screen on Windows, and a
// maximized/fullscreen window's bounds are the screen's - neither is a position the
// user chose, so only plain floating bounds are remembered.
if (state.isMinimized || state.placement != WindowPlacement.Floating) return
val position = state.position
val size = state.size
if (position !is WindowPosition.Absolute || !size.isSpecified) return
prefsRepo.setWindowBounds(
storageId,
WindowBounds(position.x.value, position.y.value, size.width.value, size.height.value)
)
}
/**
* A WindowState that starts at the window's saved position and size from window_prefs.json
* (if that position is still on a connected monitor), otherwise at the next cascade slot,
* and writes its bounds back whenever the window is moved or resized and when it closes.
*/
@Composable
fun rememberPersistentWindowState(
prefsRepo: WindowPrefsRepository,
storageId: String,
defaultSize: DpSize
): WindowState {
val (initialPosition, initialSize) = remember(storageId) {
resolveInitialPlacement(prefsRepo.boundsFor(storageId), defaultSize)
}
val state = rememberWindowState(position = initialPosition, size = initialSize)
LaunchedEffect(state, storageId) {
snapshotFlow { listOf(state.position, state.size, state.placement, state.isMinimized) }
.collectLatest {
delay(BOUNDS_SAVE_DEBOUNCE_MS)
withContext(Dispatchers.IO) { saveBoundsIfFloating(state, prefsRepo, storageId) }
}
}
// Catches a move/resize made less than BOUNDS_SAVE_DEBOUNCE_MS before the window closed.
DisposableEffect(state, storageId) {
onDispose { saveBoundsIfFloating(state, prefsRepo, storageId) }
}
return state
}