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:
co-authored by
Claude Opus 5.5
parent
242d7a7c42
commit
807903fe0f
+2
-2
@@ -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
@@ -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 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user