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"
|
||||
version = "0.1.6"
|
||||
version = "0.1.7"
|
||||
|
||||
// No project-level repositories{} block here: settings.gradle.kts sets
|
||||
// dependencyResolutionManagement.repositoriesMode = FAIL_ON_PROJECT_REPOS and already
|
||||
@@ -38,7 +38,7 @@ compose.desktop {
|
||||
appResourcesRootDir.set(project.file("installerResources"))
|
||||
targetFormats(TargetFormat.Msi, TargetFormat.Exe)
|
||||
packageName = "wIRC"
|
||||
packageVersion = "0.1.6"
|
||||
packageVersion = "0.1.7"
|
||||
windows {
|
||||
menuGroup = "wIRC"
|
||||
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.darkColorScheme
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -9,16 +10,20 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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.application
|
||||
import androidx.compose.ui.window.rememberWindowState
|
||||
import data.AliasRepository
|
||||
import data.BufferKey
|
||||
import data.MAIN_BUFFER_KEY
|
||||
import data.ServerRepository
|
||||
import data.WindowPrefsRepository
|
||||
import data.storageId
|
||||
import irc.ConnectionManager
|
||||
import ui.ChannelWindow
|
||||
import ui.MainScreen
|
||||
import ui.rememberPersistentWindowState
|
||||
import java.io.File
|
||||
|
||||
// Material 3's darkColorScheme() defaults to a purple accent; this keeps everything
|
||||
@@ -51,6 +56,13 @@ fun main() = application {
|
||||
val prefsRepo = remember { WindowPrefsRepository(appRoot) }
|
||||
|
||||
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 -
|
||||
// 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
|
||||
// listener (which the Android foreground service used to own) can live directly at
|
||||
// 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) {
|
||||
ConnectionManager.newQueryBuffers.collect { key ->
|
||||
ConnectionManager.queryWindowRequests.collect { 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
|
||||
// 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
|
||||
// apps without a tray icon follow.
|
||||
Window(
|
||||
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"),
|
||||
state = rememberWindowState()
|
||||
state = mainWindowState
|
||||
) {
|
||||
MaterialTheme(colorScheme = neutralDarkScheme) {
|
||||
Surface {
|
||||
MainScreen(
|
||||
repo = repo,
|
||||
colorScheme = neutralDarkScheme,
|
||||
displaySettings = mainDisplaySettings,
|
||||
onOpenChannel = { 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.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,
|
||||
// before alias lookup even runs) - kept here too so a line in aliases.txt can never define
|
||||
// an alias that would shadow it.
|
||||
@@ -64,15 +57,9 @@ object AliasRepository {
|
||||
* "reload" name, which this repository never loads as an alias).
|
||||
*/
|
||||
fun tryRunAlias(command: String, args: String): Boolean {
|
||||
aliasDebug("tryRunAlias: command=\"$command\" args=\"$args\"")
|
||||
val key = command.lowercase()
|
||||
if (key == RESERVED_RELOAD) return false
|
||||
val action = aliases[key]
|
||||
if (action == null) {
|
||||
aliasDebug("no alias matched for \"$key\"")
|
||||
return false
|
||||
}
|
||||
aliasDebug("matched \"$key\" -> \"$action\"")
|
||||
val action = aliases[key] ?: return false
|
||||
runAction(action, args)
|
||||
return true
|
||||
}
|
||||
@@ -94,13 +81,12 @@ object AliasRepository {
|
||||
// literal, not a URL delimiter.
|
||||
val substitutedArg = if (type == "url") encodeArgForUrl(args) else args
|
||||
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
|
||||
|
||||
try {
|
||||
when (type) {
|
||||
"path" -> { aliasDebug("Desktop.open(File(\"$target\"))"); Desktop.getDesktop().open(File(target)) }
|
||||
"url" -> { aliasDebug("Desktop.browse(URI(\"$target\"))"); Desktop.getDesktop().browse(URI(target)) }
|
||||
"path" -> Desktop.getDesktop().open(File(target))
|
||||
"url" -> Desktop.getDesktop().browse(URI(target))
|
||||
// Routed through "cmd /c start" (same launch path as double-clicking in
|
||||
// Explorer or the Run dialog) rather than exec'd directly. A direct
|
||||
// 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
|
||||
// registry / PATH - not guaranteed to work for every app, since it depends
|
||||
// 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\"")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -47,6 +47,35 @@ data class IrcMessage(
|
||||
/** Buffer key: server id + channel/query name (channel names include '#'). */
|
||||
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(
|
||||
val nick: String,
|
||||
val isOp: Boolean = false,
|
||||
|
||||
@@ -42,16 +42,10 @@ class ServerRepository(rootDir: File) {
|
||||
}
|
||||
|
||||
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 idx = current.indexOfFirst { it.id == server.id }
|
||||
if (idx >= 0) current[idx] = server else current.add(server)
|
||||
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) {
|
||||
|
||||
@@ -5,6 +5,9 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.nio.file.AtomicMoveNotSupportedException
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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
|
||||
* exposed as a StateFlow so every open window for that buffer updates live, same as
|
||||
* 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) {
|
||||
|
||||
private val file = File(rootDir, "window_prefs.json")
|
||||
private val lock = Any()
|
||||
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()
|
||||
|
||||
@@ -47,8 +72,18 @@ class WindowPrefsRepository(rootDir: File) {
|
||||
|
||||
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> =
|
||||
flows.getOrPut(bufferKeyId) { MutableStateFlow(stored[bufferKeyId] ?: WindowDisplaySettings()) }
|
||||
flows.getOrPut(bufferKeyId) { MutableStateFlow(synchronized(lock) { stored[bufferKeyId] } ?: WindowDisplaySettings()) }
|
||||
|
||||
private fun update(bufferKeyId: String, transform: (WindowDisplaySettings) -> WindowDisplaySettings) {
|
||||
synchronized(lock) {
|
||||
@@ -59,14 +94,16 @@ class WindowPrefsRepository(rootDir: File) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadAll(): MutableMap<String, WindowDisplaySettings> {
|
||||
if (!file.exists()) return mutableMapOf()
|
||||
return try {
|
||||
private fun JSONObject.optFloatOrNull(key: String): Float? =
|
||||
if (has(key) && !isNull(key)) optDouble(key).toFloat().takeIf { !it.isNaN() } else null
|
||||
|
||||
private fun loadAll() {
|
||||
if (!file.exists()) return
|
||||
try {
|
||||
val obj = JSONObject(file.readText())
|
||||
val result = mutableMapOf<String, WindowDisplaySettings>()
|
||||
obj.keys().forEach { key ->
|
||||
val o = obj.getJSONObject(key)
|
||||
result[key] = WindowDisplaySettings(
|
||||
stored[key] = WindowDisplaySettings(
|
||||
backgroundAlpha = o.optDouble("backgroundAlpha", 0.85).toFloat(),
|
||||
textColorMircIndex = o.optInt("textColorMircIndex", 0),
|
||||
fontSize = o.optDouble("fontSize", 14.0).toFloat(),
|
||||
@@ -75,28 +112,53 @@ class WindowPrefsRepository(rootDir: File) {
|
||||
nickColorsEnabled = o.optBoolean("nickColorsEnabled", true),
|
||||
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) {
|
||||
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() {
|
||||
val obj = JSONObject()
|
||||
stored.forEach { (key, settings) ->
|
||||
(stored.keys + storedBounds.keys).forEach { key ->
|
||||
obj.put(key, JSONObject().apply {
|
||||
put("backgroundAlpha", settings.backgroundAlpha)
|
||||
put("textColorMircIndex", settings.textColorMircIndex)
|
||||
put("fontSize", settings.fontSize)
|
||||
put("showUserList", settings.showUserList)
|
||||
put("mentionHighlightColorArgb", settings.mentionHighlightColorArgb)
|
||||
put("nickColorsEnabled", settings.nickColorsEnabled)
|
||||
put("showImagePreviews", settings.showImagePreviews)
|
||||
stored[key]?.let { settings ->
|
||||
put("backgroundAlpha", settings.backgroundAlpha)
|
||||
put("textColorMircIndex", settings.textColorMircIndex)
|
||||
put("fontSize", settings.fontSize)
|
||||
put("showUserList", settings.showUserList)
|
||||
put("mentionHighlightColorArgb", settings.mentionHighlightColorArgb)
|
||||
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.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.launch
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
// 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.
|
||||
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
|
||||
* windows read from the same source of truth via StateFlow.
|
||||
@@ -47,8 +63,19 @@ object ConnectionManager {
|
||||
private val _ownNicks = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||
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) {
|
||||
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) {
|
||||
@@ -56,11 +83,12 @@ object ConnectionManager {
|
||||
_ownNicks.update { it + (serverId to nick) }
|
||||
}
|
||||
|
||||
// Fires once for every brand-new private-message buffer (a query with someone
|
||||
// who wasn't already an open window). Main.kt listens to this to auto-open a
|
||||
// window for it, same as clicking a channel manually.
|
||||
private val _newQueryBuffers = MutableSharedFlow<BufferKey>(extraBufferCapacity = 16)
|
||||
val newQueryBuffers: SharedFlow<BufferKey> = _newQueryBuffers
|
||||
// Fires for every private message to or from a real user (services and the server go
|
||||
// to wIRC main instead). Main.kt adds the key to its set of open windows, which opens
|
||||
// the query window if it isn't open - including reopening one the user closed - and
|
||||
// is a no-op if it already is.
|
||||
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
|
||||
// through this single FIFO queue, processed by one consumer coroutine. Previously each
|
||||
@@ -92,10 +120,6 @@ object ConnectionManager {
|
||||
}
|
||||
|
||||
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]
|
||||
if (existing != null) {
|
||||
// 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
|
||||
// the old connection and replacing it means a connect() call always actually
|
||||
// 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()
|
||||
}
|
||||
setOwnNick(config.id, config.nick)
|
||||
@@ -113,7 +136,11 @@ object ConnectionManager {
|
||||
config = config,
|
||||
scope = scope,
|
||||
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
|
||||
conn.connect()
|
||||
@@ -133,20 +160,32 @@ object ConnectionManager {
|
||||
fun sendChannelMessage(serverId: String, target: String, text: String) {
|
||||
connectionFor(serverId)?.sendMessage(target, text)
|
||||
val myNick = ownNicks[serverId] ?: return
|
||||
val key = BufferKey(serverId, target)
|
||||
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
|
||||
// CTCP ACTION wrapper, while the buffer should just hold the plain action text
|
||||
// (ChannelScreen renders MessageKind.ACTION with its own "* nick ..." prefix).
|
||||
fun sendChannelAction(serverId: String, target: String, action: String) {
|
||||
connectionFor(serverId)?.sendMessage(target, "ACTION $action")
|
||||
connectionFor(serverId)?.sendMessage(target, "\u0001ACTION $action\u0001")
|
||||
val myNick = ownNicks[serverId] ?: return
|
||||
val key = BufferKey(serverId, target)
|
||||
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.
|
||||
@@ -154,6 +193,22 @@ object ConnectionManager {
|
||||
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
|
||||
// wire - just appended to the buffer the same way any other SYSTEM line is.
|
||||
fun addSystemMessage(serverId: String, target: String, text: String) {
|
||||
@@ -177,12 +232,6 @@ object ConnectionManager {
|
||||
if (_buffers.value.containsKey(key)) return
|
||||
val seeded = historyRepo?.loadMessages(key) ?: emptyList()
|
||||
_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) {
|
||||
@@ -195,6 +244,10 @@ object ConnectionManager {
|
||||
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
|
||||
// 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
|
||||
@@ -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) {
|
||||
val now = System.currentTimeMillis()
|
||||
val nick = nickFromPrefix(line.prefix) ?: line.prefix
|
||||
@@ -237,16 +341,46 @@ object ConnectionManager {
|
||||
"PRIVMSG" -> {
|
||||
val target = line.params.getOrNull(0) ?: return
|
||||
val text = line.params.getOrNull(1) ?: return
|
||||
val isAction = text.startsWith("ACTION") && text.endsWith("")
|
||||
val body = if (isAction) text.removePrefix("ACTION ").removeSuffix("") else text
|
||||
// Channel messages are keyed by the channel name. Private messages arrive
|
||||
// with target == our own nick, so key those by the *sender* instead -
|
||||
// that's the conversation identity a query window should use.
|
||||
val bufferName = if (isChannelName(target)) target else (nick ?: target)
|
||||
addMessage(
|
||||
BufferKey(serverId, bufferName),
|
||||
IrcMessage(now, if (isAction) MessageKind.ACTION else MessageKind.CHAT, nick, body)
|
||||
)
|
||||
val isCtcp = text.length > 1 && text.startsWith("\u0001") && text.endsWith("\u0001")
|
||||
val isAction = isCtcp && text.startsWith("\u0001ACTION")
|
||||
val body = if (isAction) text.removePrefix("\u0001ACTION ").removeSuffix("\u0001") else text
|
||||
if (isChannelName(target)) {
|
||||
addMessage(
|
||||
BufferKey(serverId, target),
|
||||
IrcMessage(now, if (isAction) MessageKind.ACTION else MessageKind.CHAT, nick, body)
|
||||
)
|
||||
} else if (isServiceOrServerPrefix(line.prefix) || (isCtcp && !isAction)) {
|
||||
// Services, the server itself, and CTCP requests other than ACTION
|
||||
// (PING, TIME, ...) go to wIRC main instead of opening a query window.
|
||||
// (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" -> {
|
||||
val channel = line.params.getOrNull(0) ?: return
|
||||
@@ -270,9 +404,13 @@ object ConnectionManager {
|
||||
}
|
||||
"MODE" -> {
|
||||
val channel = line.params.getOrNull(0) ?: return
|
||||
if (!channel.startsWith("#")) return
|
||||
val modeStr = line.params.getOrNull(1) ?: ""
|
||||
val modeTarget = line.params.getOrNull(2)
|
||||
if (!isChannelName(channel)) {
|
||||
// User mode on our own nick (e.g. +i, +r after identifying) - wIRC main.
|
||||
addMainLine(MessageKind.MODE, nick, "$nick sets mode $modeStr on $channel")
|
||||
return
|
||||
}
|
||||
addMessage(
|
||||
BufferKey(serverId, channel),
|
||||
IrcMessage(now, MessageKind.MODE, nick, "$nick sets mode $modeStr ${modeTarget ?: ""}".trim())
|
||||
@@ -315,20 +453,6 @@ object ConnectionManager {
|
||||
}
|
||||
updateUsers(BufferKey(serverId, channel)) { existing -> (existing + parsedUsers).distinctBy { it.nick } }
|
||||
}
|
||||
"NOTICE" -> {
|
||||
val target = line.params.getOrNull(0) ?: return
|
||||
val text = line.params.getOrNull(1) ?: return
|
||||
// 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" -> {
|
||||
val oldNick = nick ?: return
|
||||
val newNick = line.params.getOrNull(0) ?: return
|
||||
@@ -347,6 +471,7 @@ object ConnectionManager {
|
||||
}
|
||||
if (ownNicks[serverId] == oldNick) {
|
||||
setOwnNick(serverId, newNick)
|
||||
addMainLine(MessageKind.NICK, null, "You are now known as $newNick")
|
||||
}
|
||||
}
|
||||
"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 data.ChannelBuffer
|
||||
import data.ChannelUser
|
||||
import data.IrcMessage
|
||||
import data.MessageKind
|
||||
import data.WindowDisplaySettings
|
||||
import irc.extractImageUrls
|
||||
@@ -287,108 +288,6 @@ fun WindowScope.ChannelScreen(
|
||||
|
||||
val textColor = mircPalette[displaySettings.textColorMircIndex]
|
||||
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(
|
||||
modifier = Modifier
|
||||
@@ -483,194 +382,13 @@ fun WindowScope.ChannelScreen(
|
||||
|
||||
// Scrollback + user list, side by side, filling the available height
|
||||
Row(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
// 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
|
||||
.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))
|
||||
}
|
||||
}
|
||||
MessageScrollback(
|
||||
messages = buffer?.messages ?: emptyList(),
|
||||
displaySettings = displaySettings,
|
||||
textColor = textColor,
|
||||
isMention = { isMention(it) },
|
||||
modifier = Modifier.weight(1f).fillMaxHeight()
|
||||
)
|
||||
|
||||
// User list (tap and hold for OP actions)
|
||||
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
|
||||
// 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
|
||||
|
||||
@@ -10,8 +10,9 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
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.rememberWindowState
|
||||
import data.AliasRepository
|
||||
import data.BufferKey
|
||||
import data.WindowDisplaySettings
|
||||
@@ -59,7 +60,9 @@ fun ChannelWindow(
|
||||
val buffer by remember(bufferKey) { derivedStateOf { buffers[bufferKey] } }
|
||||
val ownNicks by ConnectionManager.ownNicksFlow.collectAsState()
|
||||
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
|
||||
// transparency (Windows only honors transparency on undecorated windows) - ChannelScreen
|
||||
@@ -124,6 +127,9 @@ fun ChannelWindow(
|
||||
ConnectionManager.sendRaw(serverId, "JOIN $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" -> {
|
||||
val msgSpaceIdx = args.indexOf(' ')
|
||||
if (msgSpaceIdx >= 0) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
@@ -11,7 +13,9 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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.KeyEventType
|
||||
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.window.DialogWindow
|
||||
import androidx.compose.ui.window.rememberDialogState
|
||||
import data.MAIN_BUFFER_KEY
|
||||
import data.ServerConfig
|
||||
import data.ServerRepository
|
||||
import data.WindowDisplaySettings
|
||||
import irc.ConnectionManager
|
||||
import irc.mircPalette
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -33,8 +40,10 @@ import kotlinx.coroutines.launch
|
||||
fun MainScreen(
|
||||
repo: ServerRepository,
|
||||
colorScheme: ColorScheme,
|
||||
displaySettings: WindowDisplaySettings,
|
||||
onOpenChannel: (serverId: String, channelName: String) -> Unit,
|
||||
onExitApp: () -> Unit
|
||||
onExitApp: () -> Unit,
|
||||
onUnreadChange: (Int) -> Unit
|
||||
) {
|
||||
var servers by remember { mutableStateOf(repo.loadAll()) }
|
||||
var showAddDialog by remember { mutableStateOf(false) }
|
||||
@@ -44,6 +53,26 @@ fun MainScreen(
|
||||
var openedChannels by remember { mutableStateOf(mapOf<String, List<String>>()) }
|
||||
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)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -125,6 +154,34 @@ fun MainScreen(
|
||||
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) {
|
||||
@@ -141,6 +198,8 @@ fun MainScreen(
|
||||
}
|
||||
}
|
||||
|
||||
private val UNREAD_INDICATOR_COLOR = Color(0xFFFFA500)
|
||||
|
||||
@Composable
|
||||
private fun ServerCard(
|
||||
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