commit defdefdb28df720cf37175ede1ab6e0bd60eb179 Author: weishaupt Date: Tue Sep 1 12:32:15 2026 -0400 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..da15e32 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.gradle/ +build/ +*.iml +.idea/ +local.properties +*.log +kotlin-js-store/ +.kotlin/ diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..5691884 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,49 @@ +import org.jetbrains.compose.desktop.application.dsl.TargetFormat + +plugins { + kotlin("jvm") version "1.9.24" + id("org.jetbrains.compose") version "1.6.11" +} + +group = "com.tpuahsiew.wirc" +version = "0.1.4" + +// No project-level repositories{} block here: settings.gradle.kts sets +// dependencyResolutionManagement.repositoriesMode = FAIL_ON_PROJECT_REPOS and already +// declares google()/mavenCentral()/the compose dev repo there, so a duplicate block in +// this file would conflict with that policy and fail the build. + +dependencies { + implementation(compose.desktop.currentOs) + implementation(compose.material3) + // Icons.Default.{Person,Settings} used by the settings-gear/user-list toggle in + // ChannelScreen live in material-icons-core, which materialIconsExtended pulls in - + // compose.material3 alone doesn't include it. + implementation(compose.materialIconsExtended) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.8.1") + implementation("org.json:json:20240303") +} + +kotlin { + jvmToolchain(17) +} + +compose.desktop { + application { + mainClass = "MainKt" + nativeDistributions { + // Bundles installerResources/ (aliases.txt starter file, etc.) into the + // installed app directory, available at runtime via the + // compose.application.resources.dir system property. + appResourcesRootDir.set(project.file("installerResources")) + targetFormats(TargetFormat.Msi, TargetFormat.Exe) + packageName = "wIRC" + packageVersion = "0.1.4" + windows { + menuGroup = "wIRC" + upgradeUuid = "8f14e45f-ceea-4a5e-9c3b-5b1f0e8c2b7a" + iconFile.set(project.file("icon.ico")) + } + } + } +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..eddabd2 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..1ef00a1 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..249efbb --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..a51ec4f --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/icon.ico b/icon.ico new file mode 100644 index 0000000..ef7dfd3 Binary files /dev/null and b/icon.ico differ diff --git a/installerResources/common/aliases.txt b/installerResources/common/aliases.txt new file mode 100644 index 0000000..7da7685 --- /dev/null +++ b/installerResources/common/aliases.txt @@ -0,0 +1,21 @@ +# wIRC alias commands. +# +# One alias per line: /name = type:target +# +# path:X opens X (a file or folder) in Explorer +# url:X opens X in the default browser +# run:X launches X as a process (full exe path, or a bare name that +# relies on Windows' App Paths registry / PATH - not guaranteed +# to work for every app) +# +# {arg} in the target is replaced with whatever text follows the command, +# e.g. "/url https://example.com" opens that exact URL. A target with no +# {arg} placeholder (like /blog below) always opens the same fixed target, +# ignoring any trailing text. +# +# Edit this file and run /reload in any wIRC window to pick up changes +# without restarting the app. + +/c = path:C:\ +/blog = url:https://bloggin.space +/url = url:{arg} diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..66c3b42 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,21 @@ +pluginManagement { + repositories { + gradlePluginPortal() + google() + mavenCentral() + maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") + } +} +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") + } +} + +rootProject.name = "wIRC-desktop" diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt new file mode 100644 index 0000000..d13e6b5 --- /dev/null +++ b/src/main/kotlin/Main.kt @@ -0,0 +1,112 @@ +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +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.window.Window +import androidx.compose.ui.window.application +import androidx.compose.ui.window.rememberWindowState +import data.AliasRepository +import data.BufferKey +import data.ServerRepository +import data.WindowPrefsRepository +import irc.ConnectionManager +import ui.ChannelWindow +import ui.MainScreen +import java.io.File + +// Material 3's darkColorScheme() defaults to a purple accent; this keeps everything +// (OutlinedTextField borders, Button/TextButton labels, etc.) neutral black-and-white +// to match the rest of the app's deliberately plain look, same as the Android version. +private val neutralDarkScheme = darkColorScheme( + primary = Color.White, + onPrimary = Color.Black, + secondary = Color.White, + onSecondary = Color.Black, + background = Color.Black, + onBackground = Color.White, + surface = Color.Black, + onSurface = Color.White, + outline = Color.White +) + +// All persistence lives under the user's home directory - the desktop analogue of +// Android's app-private filesDir, since a desktop app has no sandboxed storage of its own. +private val appRoot = File(System.getProperty("user.home"), ".wirc") + +fun main() = application { + if (!appRoot.exists()) appRoot.mkdirs() + ConnectionManager.init(appRoot) + // Loaded once here (not per-window) so the alias map is truly global - every + // ChannelWindow's command router reads from this same AliasRepository singleton. + AliasRepository.init() + + val repo = remember { ServerRepository(appRoot) } + val prefsRepo = remember { WindowPrefsRepository(appRoot) } + + var openBuffers by remember { mutableStateOf(setOf()) } + + // 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 + // and every channel window's /quit reuse the exact same logic instead of duplicating it. + val shutdownApp: () -> Unit = { + ConnectionManager.disconnectAll() + exitApplication() + } + + // Desktop OSes don't kill backgrounded network connections for battery reasons the + // way Android does, so there's no foreground-service equivalent needed here - + // 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. + LaunchedEffect(Unit) { + ConnectionManager.newQueryBuffers.collect { key -> + openBuffers = openBuffers + key + } + } + + // 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", + icon = painterResource("icon.png"), + state = rememberWindowState() + ) { + MaterialTheme(colorScheme = neutralDarkScheme) { + Surface { + MainScreen( + repo = repo, + colorScheme = neutralDarkScheme, + onOpenChannel = { serverId, channelName -> + openBuffers = openBuffers + BufferKey(serverId, channelName) + }, + onExitApp = shutdownApp + ) + } + } + } + + openBuffers.forEach { bufferKey -> + key(bufferKey) { + ChannelWindow( + bufferKey = bufferKey, + prefsRepo = prefsRepo, + colorScheme = neutralDarkScheme, + onOpenBuffer = { newKey -> openBuffers = openBuffers + newKey }, + onCloseSelf = { openBuffers = openBuffers - bufferKey }, + onQuitApp = { + exitApplication() + } + ) + } + } +} diff --git a/src/main/kotlin/data/AliasRepository.kt b/src/main/kotlin/data/AliasRepository.kt new file mode 100644 index 0000000..e65cda0 --- /dev/null +++ b/src/main/kotlin/data/AliasRepository.kt @@ -0,0 +1,217 @@ +package data + +import java.awt.Desktop +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. +private const val RESERVED_RELOAD = "reload" + +private val defaultAliasesContent = """ + # wIRC alias commands. One alias per line: /name = type:target + # path:X opens X (a file or folder) in Explorer + # url:X opens X in the default browser + # run:X launches X as a process + # {arg} in the target is replaced with whatever text follows the command. + /c = path:C:\ + /blog = url:https://bloggin.space + /url = url:{arg} +""".trimIndent() + "\n" + +/** + * Global (not per-server/per-channel) alias command table, loaded once at app startup + * (see Main.kt) and shared by every ChannelWindow's command router. + * + * aliases.txt is bundled into the install directory by build.gradle.kts's + * appResourcesRootDir (see installerResources/aliases.txt), which puts it somewhere under + * Program Files - normally UAC-protected. On [init], this repository figures out whether + * that bundled copy can actually be hand-edited in place; if not, it falls back to a + * writable copy under %APPDATA%\wIRC instead and uses that from then on. + */ +object AliasRepository { + + private var aliases: Map = emptyMap() + + lateinit var activeFile: File + private set + + fun init() { + activeFile = resolveAliasFile() + aliases = loadAliases(activeFile) + println("[AliasRepository] Using aliases file: ${activeFile.absolutePath} (${aliases.size} aliases loaded)") + } + + /** Re-reads aliases.txt from the currently active path. Returns the number of aliases loaded. */ + fun reload(): Int { + aliases = loadAliases(activeFile) + return aliases.size + } + + /** + * Returns true if [command] (without the leading '/') matched a known alias and its + * action was run - callers should treat that as "handled, don't fall through to IRC + * command handling". Returns false for unknown commands (including the reserved + * "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\"") + runAction(action, args) + return true + } + + private fun runAction(action: String, args: String) { + val colonIdx = action.indexOf(':') + if (colonIdx < 0) return + val type = action.substring(0, colonIdx).trim() + val rawTarget = action.substring(colonIdx + 1).trim() + + // URL targets need their {arg} substitution percent-encoded - java.net.URI's + // single-String constructor does strict RFC 2396 parsing and throws + // URISyntaxException on a raw space (e.g. "/src alexis road" against + // "url:...?search={arg}" produces "...?search=alexis road", which URI() rejects + // outright). That exception used to be swallowed by the catch below with only a + // println - invisible in a windowed app with no attached console, so a multi-word + // arg looked like the alias silently did nothing. path/run targets are real + // filesystem paths or process args, so they're left unencoded - a space there is + // 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)) } + // 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 + // an exe's embedded "requireAdministrator" manifest - it just fails with + // CreateProcess error 740 (ERROR_ELEVATION_REQUIRED). "start" goes through + // ShellExecute instead, which shows the same UAC prompt a normal launch + // 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() } + else -> println("[AliasRepository] Unknown alias action type '$type' in \"$action\"") + } + } catch (e: Exception) { + println("[AliasRepository] Failed to run alias action \"$action\" (target=\"$target\"): ${e.message}") + } + } + + // Percent-encodes only what would actually break URI parsing or misrepresent the + // argument (whitespace, quotes, non-ASCII, etc.), matching the standard + // application/x-www-form-urlencoded convention of encoding spaces as '+' (the ticket's + // expected "search=alexis+road" output). Structural/reserved URI characters + // (: / ? # & = etc.) are passed through unencoded so an alias like "/url = url:{arg}" + // still works when {arg} is itself a full URL rather than a query value. + private fun encodeArgForUrl(args: String): String { + val safe = "-_.~:/?#[]@!$&'()*+,;=%" + val sb = StringBuilder() + for (c in args) { + when { + c == ' ' -> sb.append('+') + c.isLetterOrDigit() && c.code < 128 -> sb.append(c) + c in safe -> sb.append(c) + else -> sb.append(URLEncoder.encode(c.toString(), "UTF-8")) + } + } + return sb.toString() + } + + private fun loadAliases(file: File): Map { + if (!file.exists()) return emptyMap() + val result = mutableMapOf() + file.readLines().forEach { line -> + val trimmed = line.trim() + if (trimmed.isEmpty() || trimmed.startsWith("#")) return@forEach + val eqIdx = trimmed.indexOf('=') + if (eqIdx < 0) return@forEach + val name = trimmed.substring(0, eqIdx).trim().removePrefix("/").lowercase() + val action = trimmed.substring(eqIdx + 1).trim() + if (name.isEmpty() || action.isEmpty() || name == RESERVED_RELOAD) return@forEach + result[name] = action + } + return result + } + + private fun resolveAliasFile(): File { + val appDataFile = File(File(appDataRoot(), "wIRC"), "aliases.txt") + val resourcesDirProp = System.getProperty("compose.application.resources.dir") + + if (resourcesDirProp != null) { + val bundled = File(resourcesDirProp, "aliases.txt") + if (bundled.exists()) { + return if (isDirectoryWritable(bundled.parentFile)) { + // Program Files write protection isn't active (or this isn't an + // installed build) - edit the bundled copy in place, as intended. + bundled + } else { + println( + "[AliasRepository] ${bundled.absolutePath} is not writable " + + "(Program Files protection) - falling back to ${appDataFile.absolutePath}" + ) + if (!appDataFile.exists()) { + appDataFile.parentFile?.mkdirs() + // Best-effort: carry over the bundled starter content. If even + // reading it fails (locked file, odd ACLs, etc.) fall back to the + // built-in default rather than leaving init() to crash the app. + try { + bundled.copyTo(appDataFile, overwrite = false) + } catch (e: Exception) { + println("[AliasRepository] Couldn't copy bundled aliases.txt (${e.message}) - writing default starter content instead") + appDataFile.writeText(defaultAliasesContent) + } + } + appDataFile + } + } + } + + // Dev run (no installer resources dir set) or bundled file missing - seed a + // starter file directly under %APPDATA% so the feature still works from source. + if (!appDataFile.exists()) { + appDataFile.parentFile?.mkdirs() + appDataFile.writeText(defaultAliasesContent) + } + return appDataFile + } + + private fun appDataRoot(): File = + System.getenv("APPDATA")?.let { File(it) } + ?: File(System.getProperty("user.home"), "AppData/Roaming") + + // File.canWrite() can lie (or simply be unavailable) on some Windows ACL setups, so + // this confirms writability the same way the file will actually be edited: a real + // write followed by cleanup. + private fun isDirectoryWritable(dir: File?): Boolean { + if (dir == null) return false + return try { + val probe = File(dir, ".wirc_write_test_${System.nanoTime()}.tmp") + probe.writeText("") + probe.delete() + true + } catch (e: Exception) { + false + } + } +} diff --git a/src/main/kotlin/data/MessageHistoryRepository.kt b/src/main/kotlin/data/MessageHistoryRepository.kt new file mode 100644 index 0000000..b5ca645 --- /dev/null +++ b/src/main/kotlin/data/MessageHistoryRepository.kt @@ -0,0 +1,57 @@ +package data + +import org.json.JSONArray +import org.json.JSONObject +import java.io.File + +/** + * Persists each buffer's scrollback to a JSON file under rootDir/history/, so messages + * survive an app restart. Capped at 500 messages per buffer, matching the in-memory cap + * ConnectionManager already applies. + */ +class MessageHistoryRepository(private val rootDir: File) { + + private fun fileFor(key: BufferKey): File { + val dir = File(rootDir, "history") + if (!dir.exists()) dir.mkdirs() + val safeName = key.name.replace(Regex("[^a-zA-Z0-9]"), "_") + return File(dir, "${key.serverId}__${safeName}.json") + } + + fun loadMessages(key: BufferKey): List { + val file = fileFor(key) + if (!file.exists()) return emptyList() + return try { + val arr = JSONArray(file.readText()) + (0 until arr.length()).map { i -> fromJson(arr.getJSONObject(i)) } + } catch (e: Exception) { + emptyList() + } + } + + fun saveMessages(key: BufferKey, messages: List) { + val arr = JSONArray() + messages.takeLast(500).forEach { arr.put(toJson(it)) } + fileFor(key).writeText(arr.toString()) + } + + private fun toJson(m: IrcMessage): JSONObject = JSONObject().apply { + put("timestampMillis", m.timestampMillis) + put("kind", m.kind.name) + put("sender", m.sender ?: JSONObject.NULL) + put("rawText", m.rawText) + put("target", m.target ?: JSONObject.NULL) + } + + private fun fromJson(o: JSONObject): IrcMessage = IrcMessage( + timestampMillis = o.optLong("timestampMillis", 0L), + kind = try { + MessageKind.valueOf(o.optString("kind", "SYSTEM")) + } catch (e: Exception) { + MessageKind.SYSTEM + }, + sender = o.optString("sender", null).takeIf { it != "null" }, + rawText = o.optString("rawText", ""), + target = o.optString("target", null).takeIf { it != "null" } + ) +} diff --git a/src/main/kotlin/data/Models.kt b/src/main/kotlin/data/Models.kt new file mode 100644 index 0000000..84c4e54 --- /dev/null +++ b/src/main/kotlin/data/Models.kt @@ -0,0 +1,61 @@ +package data + +/** A configured IRC server/network connection. */ +data class ServerConfig( + val id: String, // stable key, e.g. "rizon" + val label: String, // display name, e.g. "Rizon" + val host: String, + val port: Int = 6667, + val useTls: Boolean = false, + val nick: String, + val autoJoinChannels: List = emptyList(), + val saslUser: String? = null, + val saslPass: String? = null, + // Raw IRC commands sent right after registration (001), before auto-join channels. + // e.g. "PRIVMSG NickServ :IDENTIFY hunter2" or "NS IDENTIFY hunter2". + // This is the "on join perform" list — saved to disk and replayed every connect. + val onConnectCommands: List = emptyList() +) + +/** True for channel-style targets ('#', '&', '+', '!' prefixes per the IRC spec). */ +fun isChannelName(name: String): Boolean = + name.isNotEmpty() && name[0] in "#&+!" + +enum class MessageKind { CHAT, ACTION, JOIN, PART, QUIT, KICK, MODE, NOTICE, NICK, TOPIC, SYSTEM } + +// Process-wide, so every IrcMessage ever constructed while the app is running gets a +// genuinely unique seq, regardless of which buffer/server it belongs to. +private val nextMessageSeq = java.util.concurrent.atomic.AtomicLong(0) + +data class IrcMessage( + val timestampMillis: Long, + val kind: MessageKind, + val sender: String?, // nick, or null for system/server lines + val rawText: String, // still contains mIRC control codes; parsed at render time + val target: String? = null, // e.g. for KICK, the nick that got kicked + // Monotonically increasing, guaranteed-unique identity for this message - used as the + // LazyColumn item key in ChannelScreen (see there). The old key there was + // "${timestampMillis}_${sender}_${rawText.hashCode()}" - in a genuinely busy channel, + // two messages sharing the same millisecond, sender, and text (not rare for short + // repeated lines - "lol", a single emoji, etc.) would produce the exact same key, and + // Compose requires unique keys per item. Not persisted to disk - history reloaded on + // restart just gets fresh seq values in load order, which is fine since uniqueness only + // needs to hold within a single running session, not across restarts. + val seq: Long = nextMessageSeq.getAndIncrement() +) + +/** Buffer key: server id + channel/query name (channel names include '#'). */ +data class BufferKey(val serverId: String, val name: String) + +data class ChannelUser( + val nick: String, + val isOp: Boolean = false, + val isVoice: Boolean = false +) + +data class ChannelBuffer( + val key: BufferKey, + val messages: List = emptyList(), + val users: List = emptyList(), + val topic: String = "" +) diff --git a/src/main/kotlin/data/ServerRepository.kt b/src/main/kotlin/data/ServerRepository.kt new file mode 100644 index 0000000..41c46ff --- /dev/null +++ b/src/main/kotlin/data/ServerRepository.kt @@ -0,0 +1,103 @@ +package data + +import org.json.JSONArray +import org.json.JSONObject +import java.io.File + +/** + * Desktop equivalent of the Android EncryptedSharedPreferences-backed repository. + * Persists the server list — including each server's "on join perform" commands + * (NickServ/ChanServ identify, etc.) — as a single JSON file (rootDir/servers.json). + * Loaded once at app start and replayed automatically on every connect, so you only + * ever type your identify commands in once. + * + * NOTE ON SECRECY: the Android version encrypts this at rest via EncryptedSharedPreferences + * backed by an Android Keystore master key, so identify passwords never sit in plaintext. + * There is no OS-backed secret store used here — desktop has no direct equivalent that + * this project depends on. This file is plaintext JSON; the best this does is restrict + * its OS file permissions to the current user (best-effort via File.setReadable/setWritable, + * since Windows has no POSIX permission bits to set directly). That is meaningfully weaker + * than the Android Keystore-backed encryption - treat it accordingly. + */ +class ServerRepository(rootDir: File) { + + private val file = File(rootDir, "servers.json") + + fun loadAll(): List { + if (!file.exists()) return emptyList() + return try { + val arr = JSONArray(file.readText()) + (0 until arr.length()).map { i -> fromJson(arr.getJSONObject(i)) } + } catch (e: Exception) { + emptyList() + } + } + + fun saveAll(servers: List) { + val arr = JSONArray() + servers.forEach { arr.put(toJson(it)) } + file.parentFile?.mkdirs() + file.writeText(arr.toString()) + restrictToOwner(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) { + saveAll(loadAll().filterNot { it.id == serverId }) + } + + // Best-effort "only the current user can read this" - Windows has no POSIX mode + // bits, so File.setReadable/setWritable(_, ownerOnly = true) is the closest plain + // java.io.File gets; it maps to denying the "Everyone" ACL entry rather than a + // real per-user ACL, which is weaker than the Android Keystore-backed version. + private fun restrictToOwner(file: File) { + file.setReadable(false, false) + file.setReadable(true, true) + file.setWritable(false, false) + file.setWritable(true, true) + } + + private fun toJson(s: ServerConfig): JSONObject = JSONObject().apply { + put("id", s.id) + put("label", s.label) + put("host", s.host) + put("port", s.port) + put("useTls", s.useTls) + put("nick", s.nick) + put("autoJoinChannels", JSONArray(s.autoJoinChannels)) + put("saslUser", s.saslUser ?: JSONObject.NULL) + put("saslPass", s.saslPass ?: JSONObject.NULL) + put("onConnectCommands", JSONArray(s.onConnectCommands)) + } + + private fun fromJson(o: JSONObject): ServerConfig { + fun strArray(key: String): List { + val arr = o.optJSONArray(key) ?: return emptyList() + return (0 until arr.length()).map { arr.getString(it) } + } + return ServerConfig( + id = o.getString("id"), + label = o.getString("label"), + host = o.getString("host"), + port = o.optInt("port", 6667), + useTls = o.optBoolean("useTls", false), + nick = o.getString("nick"), + autoJoinChannels = strArray("autoJoinChannels"), + saslUser = o.optString("saslUser", null).takeIf { it != "null" }, + saslPass = o.optString("saslPass", null).takeIf { it != "null" }, + onConnectCommands = strArray("onConnectCommands") + ) + } +} diff --git a/src/main/kotlin/data/WindowPrefsRepository.kt b/src/main/kotlin/data/WindowPrefsRepository.kt new file mode 100644 index 0000000..84b87c5 --- /dev/null +++ b/src/main/kotlin/data/WindowPrefsRepository.kt @@ -0,0 +1,103 @@ +package data + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.json.JSONObject +import java.io.File +import java.util.concurrent.ConcurrentHashMap + +data class WindowDisplaySettings( + val backgroundAlpha: Float = 0.85f, // 0f = fully transparent, 1f = fully opaque + val textColorMircIndex: Int = 0, // index into the standard mIRC palette; 0 = white + val fontSize: Float = 14f, + val showUserList: Boolean = true, + val mentionHighlightColorArgb: Int = 0x40FFA500.toInt(), // translucent orange + val nickColorsEnabled: Boolean = true, + val showImagePreviews: Boolean = false // opt-in: auto-loading images reveals your IP to the host +) + +/** + * 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. + */ +class WindowPrefsRepository(rootDir: File) { + + private val file = File(rootDir, "window_prefs.json") + private val lock = Any() + private val flows = ConcurrentHashMap>() + private var stored: MutableMap = loadAll() + + fun settingsFor(bufferKeyId: String): StateFlow = flowFor(bufferKeyId).asStateFlow() + + fun setAlpha(bufferKeyId: String, alpha: Float) = update(bufferKeyId) { it.copy(backgroundAlpha = alpha) } + + fun setTextColorMircIndex(bufferKeyId: String, index: Int) = update(bufferKeyId) { it.copy(textColorMircIndex = index) } + + fun setFontSize(bufferKeyId: String, fontSize: Float) = update(bufferKeyId) { it.copy(fontSize = fontSize) } + + fun setShowUserList(bufferKeyId: String, show: Boolean) = update(bufferKeyId) { it.copy(showUserList = show) } + + fun setMentionHighlightColor(bufferKeyId: String, colorArgb: Int) = update(bufferKeyId) { it.copy(mentionHighlightColorArgb = colorArgb) } + + fun setNickColorsEnabled(bufferKeyId: String, enabled: Boolean) = update(bufferKeyId) { it.copy(nickColorsEnabled = enabled) } + + fun setShowImagePreviews(bufferKeyId: String, show: Boolean) = update(bufferKeyId) { it.copy(showImagePreviews = show) } + + private fun flowFor(bufferKeyId: String): MutableStateFlow = + flows.getOrPut(bufferKeyId) { MutableStateFlow(stored[bufferKeyId] ?: WindowDisplaySettings()) } + + private fun update(bufferKeyId: String, transform: (WindowDisplaySettings) -> WindowDisplaySettings) { + synchronized(lock) { + val updated = transform(stored[bufferKeyId] ?: WindowDisplaySettings()) + stored[bufferKeyId] = updated + flowFor(bufferKeyId).value = updated + persist() + } + } + + private fun loadAll(): MutableMap { + if (!file.exists()) return mutableMapOf() + return try { + val obj = JSONObject(file.readText()) + val result = mutableMapOf() + obj.keys().forEach { key -> + val o = obj.getJSONObject(key) + result[key] = WindowDisplaySettings( + backgroundAlpha = o.optDouble("backgroundAlpha", 0.85).toFloat(), + textColorMircIndex = o.optInt("textColorMircIndex", 0), + fontSize = o.optDouble("fontSize", 14.0).toFloat(), + showUserList = o.optBoolean("showUserList", true), + mentionHighlightColorArgb = o.optLong("mentionHighlightColorArgb", 0x40FFA500L).toInt(), + nickColorsEnabled = o.optBoolean("nickColorsEnabled", true), + showImagePreviews = o.optBoolean("showImagePreviews", false) + ) + } + result + } catch (e: Exception) { + mutableMapOf() + } + } + + private fun persist() { + val obj = JSONObject() + stored.forEach { (key, settings) -> + 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) + }) + } + file.parentFile?.mkdirs() + file.writeText(obj.toString()) + } +} + +fun BufferKey.storageId(): String = "${serverId}__${name}" diff --git a/src/main/kotlin/irc/ConnectionManager.kt b/src/main/kotlin/irc/ConnectionManager.kt new file mode 100644 index 0000000..212c383 --- /dev/null +++ b/src/main/kotlin/irc/ConnectionManager.kt @@ -0,0 +1,343 @@ +package irc + +import data.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.io.File +import java.util.concurrent.ConcurrentHashMap + +// Per-buffer message history cap. Named so it's a one-line change to temporarily lower +// for testing what happens once a busy channel's buffer actually hits the cap (auto-scroll +// keying on the wrong thing is invisible in a quiet test channel that never reaches this). +private const val MESSAGE_HISTORY_CAP = 500 + +// How long to wait after a message before actually writing that buffer's history to disk, +// coalescing any further messages that arrive in the meantime into the same write. See +// scheduleSave() - previously every single message triggered its own full save, which is +// a lot of disk I/O to keep up with in a busy channel and was the likely direct cause of +// reported slowness under real message volume. +private const val SAVE_DEBOUNCE_MS = 2500L + +/** + * App-wide singleton. Owns every IrcConnection and every ChannelBuffer, so that all + * windows read from the same source of truth via StateFlow. + */ +object ConnectionManager { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val connections = ConcurrentHashMap() + private val ownNicks = ConcurrentHashMap() // serverId -> our current nick + private var historyRepo: MessageHistoryRepository? = null + + private val _buffers = MutableStateFlow>(emptyMap()) + val buffers: StateFlow> = _buffers + + private val _statuses = MutableStateFlow>(emptyMap()) + val statuses: StateFlow> = _statuses + + private val _ownNicks = MutableStateFlow>(emptyMap()) + val ownNicksFlow: StateFlow> = _ownNicks + + fun init(rootDir: File) { + historyRepo = MessageHistoryRepository(rootDir) + } + + private fun setOwnNick(serverId: String, nick: String) { + ownNicks[serverId] = nick + _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(extraBufferCapacity = 16) + val newQueryBuffers: SharedFlow = _newQueryBuffers + + 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 + // rest of the process once a server had been connected once - editing that + // server's config afterward (identify commands, autojoin, etc.) and re-saving + // had zero effect until a full app restart, because the running IrcConnection's + // `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) + val conn = IrcConnection( + config = config, + scope = scope, + onLine = { line -> handleLine(config.id, line) }, + onStatus = { msg -> _statuses.update { it + (config.id to msg) } } + ) + connections[config.id] = conn + conn.connect() + } + + fun disconnectAll() { + connections.values.forEach { it.disconnect() } + connections.clear() + flushAllSaves() + } + + fun connectionFor(serverId: String): IrcConnection? = connections[serverId] + + // IRC servers don't echo PRIVMSG back to its sender, so a sent message has to be + // added to our own buffer locally - otherwise it would never show up in the window + // that sent it. + fun sendChannelMessage(serverId: String, target: String, text: String) { + connectionFor(serverId)?.sendMessage(target, text) + val myNick = ownNicks[serverId] ?: return + addMessage( + BufferKey(serverId, target), + IrcMessage(System.currentTimeMillis(), MessageKind.CHAT, myNick, text) + ) + } + + // 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") + val myNick = ownNicks[serverId] ?: return + addMessage( + BufferKey(serverId, target), + IrcMessage(System.currentTimeMillis(), MessageKind.ACTION, myNick, action) + ) + } + + // Raw passthrough for slash commands that don't have dedicated handling. + fun sendRaw(serverId: String, raw: String) { + connectionFor(serverId)?.send(raw) + } + + // 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) { + addMessage(BufferKey(serverId, target), IrcMessage(System.currentTimeMillis(), MessageKind.SYSTEM, null, text)) + } + + // One mutex per buffer key, guarding the "is this buffer new -> seed its history from + // disk if so -> append the new message" sequence for that key end to end. Previously + // that sequence was: a synchronous isNewBuffer read, then a separate async coroutine + // that did the disk seed-load and the in-memory update - two steps with a real window + // between them. In a busy channel, several messages for a brand-new buffer (e.g. right + // after JOIN, when NAMES/TOPIC/a flood of chat lines all land within milliseconds) could + // each see isNewBuffer=true before any of them had finished writing, each independently + // re-reading the same stale on-disk snapshot and racing to write - concretely wasteful + // (redundant disk reads) and, since MessageHistoryRepository.saveMessages() does a plain + // unsynchronized file.writeText() with no ordering guarantee between concurrent callers, + // capable of persisting a stale/incomplete snapshot last even when the in-memory state + // was already fully correct. A per-key Mutex makes the whole check-seed-append sequence + // for a given key strictly one-at-a-time, so only the genuinely first caller ever seeds + // from disk and no two writers for the same key can be in that window simultaneously. + private val bufferMutexes = ConcurrentHashMap() + private fun mutexFor(key: BufferKey): Mutex = bufferMutexes.computeIfAbsent(key) { Mutex() } + + // Pending debounced saves, keyed by buffer - see scheduleSave(). + private val pendingSaveJobs = ConcurrentHashMap() + + private fun addMessage(key: BufferKey, msg: IrcMessage) { + // Do the file I/O (seeding a brand-new buffer from disk, then persisting the + // updated list) off the caller's thread - addMessage gets called both from the + // IO-dispatched read loop and directly from UI-thread local-echo sends. + scope.launch(Dispatchers.IO) { + mutexFor(key).withLock { + val isNewBuffer = !_buffers.value.containsKey(key) + val seeded = if (isNewBuffer) (historyRepo?.loadMessages(key) ?: emptyList()) else emptyList() + _buffers.update { map -> + val existing = map[key] ?: ChannelBuffer(key, messages = seeded) + val trimmed = (existing.messages + msg).takeLast(MESSAGE_HISTORY_CAP) + map + (key to existing.copy(messages = trimmed)) + } + if (isNewBuffer && !isChannelName(key.name)) { + _newQueryBuffers.emit(key) + } + } + } + scheduleSave(key) + } + + // 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 + // _buffers.value fresh at write time (not at schedule time), so whichever message + // triggers the eventual write, it always persists the latest state, not a stale + // snapshot from whenever it happened to be scheduled. computeIfAbsent is atomic, so + // concurrent callers for the same key can only ever schedule one pending job. + private fun scheduleSave(key: BufferKey) { + pendingSaveJobs.computeIfAbsent(key) { + scope.launch(Dispatchers.IO) { + delay(SAVE_DEBOUNCE_MS) + pendingSaveJobs.remove(key) + val toSave = _buffers.value[key]?.messages ?: emptyList() + historyRepo?.saveMessages(key, toSave) + } + } + } + + // Bypasses the debounce and writes every buffer's current state immediately - called on + // shutdown so the last few seconds of a debounce window are never silently lost. + private fun flushAllSaves() { + pendingSaveJobs.values.forEach { it.cancel() } + pendingSaveJobs.clear() + _buffers.value.forEach { (key, buffer) -> historyRepo?.saveMessages(key, buffer.messages) } + } + + private fun updateUsers(key: BufferKey, transform: (List) -> List) { + _buffers.update { map -> + val existing = map[key] ?: ChannelBuffer(key) + map + (key to existing.copy(users = transform(existing.users))) + } + } + + private fun handleLine(serverId: String, line: ParsedLine) { + val now = System.currentTimeMillis() + val nick = nickFromPrefix(line.prefix) ?: line.prefix + + when (line.command) { + "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) + ) + } + "JOIN" -> { + val channel = line.params.getOrNull(0) ?: return + addMessage(BufferKey(serverId, channel), IrcMessage(now, MessageKind.JOIN, nick, "$nick joined $channel")) + if (nick != null) updateUsers(BufferKey(serverId, channel)) { it + ChannelUser(nick) } + } + "PART" -> { + val channel = line.params.getOrNull(0) ?: return + addMessage(BufferKey(serverId, channel), IrcMessage(now, MessageKind.PART, nick, "$nick left $channel")) + if (nick != null) updateUsers(BufferKey(serverId, channel)) { users -> users.filterNot { it.nick == nick } } + } + "KICK" -> { + val channel = line.params.getOrNull(0) ?: return + val kicked = line.params.getOrNull(1) ?: return + val reason = line.params.getOrNull(2) ?: "" + addMessage( + BufferKey(serverId, channel), + IrcMessage(now, MessageKind.KICK, nick, "$kicked was kicked by $nick ($reason)", target = kicked) + ) + updateUsers(BufferKey(serverId, channel)) { users -> users.filterNot { it.nick == kicked } } + } + "MODE" -> { + val channel = line.params.getOrNull(0) ?: return + if (!channel.startsWith("#")) return + val modeStr = line.params.getOrNull(1) ?: "" + val modeTarget = line.params.getOrNull(2) + addMessage( + BufferKey(serverId, channel), + IrcMessage(now, MessageKind.MODE, nick, "$nick sets mode $modeStr ${modeTarget ?: ""}".trim()) + ) + if (modeTarget != null && (modeStr == "+o" || modeStr == "-o" || modeStr == "+v" || modeStr == "-v")) { + updateUsers(BufferKey(serverId, channel)) { users -> + users.map { u -> + if (u.nick != modeTarget) u + else when (modeStr) { + "+o" -> u.copy(isOp = true) + "-o" -> u.copy(isOp = false) + "+v" -> u.copy(isVoice = true) + "-v" -> u.copy(isVoice = false) + else -> u + } + } + } + } + } + "TOPIC" -> { + val channel = line.params.getOrNull(0) ?: return + val topic = line.params.getOrNull(1) ?: "" + _buffers.update { map -> + val key = BufferKey(serverId, channel) + val existing = map[key] ?: ChannelBuffer(key) + map + (key to existing.copy(topic = topic)) + } + addMessage(BufferKey(serverId, channel), IrcMessage(now, MessageKind.TOPIC, nick, "$nick changed topic: $topic")) + } + "353" -> { // NAMES reply + val channel = line.params.getOrNull(2) ?: return + val names = line.params.getOrNull(3)?.split(" ")?.filter { it.isNotBlank() } ?: return + val parsedUsers = names.map { raw -> + when { + raw.startsWith("@") -> ChannelUser(raw.removePrefix("@"), isOp = true) + raw.startsWith("+") -> ChannelUser(raw.removePrefix("+"), isVoice = true) + else -> ChannelUser(raw) + } + } + 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 + val affectedKeys = _buffers.value.filterKeys { it.serverId == serverId } + .filterValues { buf -> buf.users.any { it.nick == oldNick } } + .keys + _buffers.update { map -> + map.mapValues { (key, buf) -> + if (key.serverId == serverId && buf.users.any { it.nick == oldNick }) { + buf.copy(users = buf.users.map { u -> if (u.nick == oldNick) u.copy(nick = newNick) else u }) + } else buf + } + } + affectedKeys.forEach { key -> + addMessage(key, IrcMessage(now, MessageKind.SYSTEM, null, "$oldNick is now known as $newNick")) + } + if (ownNicks[serverId] == oldNick) { + setOwnNick(serverId, newNick) + } + } + "QUIT" -> { + val reason = line.params.getOrNull(0) ?: "" + // Remove nick from every channel buffer on this server it appears in. + _buffers.update { map -> + map.mapValues { (key, buf) -> + if (key.serverId == serverId && nick != null && buf.users.any { it.nick == nick }) { + buf.copy(users = buf.users.filterNot { it.nick == nick }) + } else buf + } + } + } + } + } +} diff --git a/src/main/kotlin/irc/IrcConnection.kt b/src/main/kotlin/irc/IrcConnection.kt new file mode 100644 index 0000000..53edbf7 --- /dev/null +++ b/src/main/kotlin/irc/IrcConnection.kt @@ -0,0 +1,288 @@ +package irc + +import data.ServerConfig +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel +import java.io.BufferedReader +import java.io.InputStreamReader +import java.io.OutputStreamWriter +import java.io.PrintWriter +import java.net.Socket +import javax.net.ssl.SSLSocket +import javax.net.ssl.SSLSocketFactory + +// TEMP DEBUG (see task: closed-channel-doesn't-receive-messages investigation) - remove +// once the overnight rejoin issue is confirmed fixed. Prefixed so it's easy to grep out. +private fun ircDebug(serverId: String, msg: String) { + println("[irc-debug][$serverId] $msg") +} + +// Extra buffer after end-of-MOTD (376/422) before actually sending on-connect commands - +// see runPostRegistration(). Some networks' services genuinely aren't ready the same +// instant MOTD ends; this is cheap insurance on top of that already-real trigger. +private const val POST_MOTD_IDENTIFY_DELAY_MS = 1500L + +// on-connect commands are meant to be raw IRC protocol lines (e.g. +// "PRIVMSG NickServ :IDENTIFY password"), but the app's own chat window teaches +// "/msg NickServ IDENTIFY password" as the correct thing to type (it IS correct there - +// ChannelWindow.kt's onSend translates that shorthand before sending) - an easy, natural +// mistake to reuse that exact phrasing in the on-connect-commands field, which has no such +// translation and just writes the string to the socket byte-for-byte. A real network +// confirmed this: the raw line got rejected outright with "421 Unknown command" before +// NickServ ever saw it, since IRC servers have no "/msg" command. This makes on-connect +// commands tolerant of that shorthand too, so either form works. +private fun translateOnConnectCommand(cmd: String): String { + val trimmed = cmd.trim() + if (!trimmed.startsWith("/msg ", ignoreCase = true)) return cmd + val rest = trimmed.substring(5).trim() + val spaceIdx = rest.indexOf(' ') + if (spaceIdx <= 0) return cmd + val target = rest.substring(0, spaceIdx) + val message = rest.substring(spaceIdx + 1).trim() + if (message.isEmpty()) return cmd + return "PRIVMSG $target :$message" +} + +/** + * Owns one socket to one IRC server. Emits raw parsed lines via onLine. + * Handles registration (NICK/USER), and basic auto-reconnect with backoff. + */ +class IrcConnection( + val config: ServerConfig, + private val scope: CoroutineScope, + private val onLine: (ParsedLine) -> Unit, + private val onStatus: (String) -> Unit +) { + // @Volatile: read from the outbox-consumer coroutine, written from the read-loop + // coroutine - without it there's no cross-thread visibility guarantee, so the outbox + // consumer could keep observing a stale non-null `writer` from a socket that already + // closed (or a stale null after a new one connected), silently mis-routing sends. + @Volatile private var socket: Socket? = null + @Volatile private var writer: PrintWriter? = null + private var readJob: Job? = null + private var wantConnected = false + private var backoffMillis = 2000L + + // Every channel we're currently in, whether from config.autoJoinChannels or joined + // manually mid-session - so a reconnect rejoins everything, not just the original + // auto-join list. + private val joinedChannels = java.util.concurrent.CopyOnWriteArraySet() + + // Guards runPostRegistration() against running twice for the same connection cycle + // (376 and 422 are mutually exclusive per RFC, but this is cheap insurance) - reset + // to false at the top of every connect attempt so a reconnect re-arms it. + @Volatile private var registeredHandled = false + + // Outgoing lines go through a single serial consumer instead of one + // scope.launch(Dispatchers.IO) per send() call. That old approach had two problems: + // (1) no ordering guarantee between independently-scheduled coroutines on a thread + // pool dispatcher, and (2) if the socket dropped between a send() call being made and + // its coroutine actually running, `writer` would already be null and the line was + // silently discarded with no trace - which made a dropped JOIN indistinguishable from + // one that was never requested. This queue preserves order and gives a single place + // to log what actually made it onto the wire vs. what got dropped. + private val outbox = Channel(Channel.UNLIMITED) + + init { + scope.launch(Dispatchers.IO) { + for (raw in outbox) { + val w = writer + if (w == null) { + // Kept (unlike the old per-line CHECKPOINT4 write-confirmation logging, + // removed - it fired on every single outbound line and was real + // console-I/O overhead under busy-channel volume, per println being + // synchronized/blocking) - this specific case should be rare and is + // worth knowing about if it ever happens. + ircDebug(config.id, "DROPPED (writer is null - not connected): \"$raw\"") + } else { + w.println(raw) + } + } + } + } + + fun connect() { + wantConnected = true + readJob?.cancel() + readJob = scope.launch(Dispatchers.IO) { + while (wantConnected && isActive) { + var fallbackJob: Job? = null + try { + onStatus("Connecting to ${config.host}:${config.port}...") + ircDebug(config.id, "connecting to ${config.host}:${config.port} (tracked channels: $joinedChannels)") + val sock: Socket = if (config.useTls) { + val sslSocket = SSLSocketFactory.getDefault().createSocket(config.host, config.port) as SSLSocket + val sslParams = sslSocket.sslParameters + sslParams.endpointIdentificationAlgorithm = "HTTPS" + sslSocket.sslParameters = sslParams + sslSocket.startHandshake() + sslSocket + } else { + Socket(config.host, config.port) + } + socket = sock + val out = PrintWriter(OutputStreamWriter(sock.getOutputStream(), Charsets.UTF_8), true) + writer = out + + // Used to send "CAP REQ :sasl" here whenever config.saslUser was set, but + // there was never any AUTHENTICATE/CAP END follow-through - SASL isn't + // actually implemented (there's no UI to even set saslUser/saslPass). Per + // the IRCv3 CAP spec, a compliant server MUST hold off sending 001 until + // it sees CAP END, so starting a negotiation the client can never finish + // risked silently stalling registration forever on strict servers - + // meaning 001/376/422 (and therefore the on-connect identify command) + // would never fire at all. Removed until SASL is actually implemented + // end-to-end. + out.println("NICK ${config.nick}") + out.println("USER ${config.nick} 0 * :${config.nick}") + + backoffMillis = 2000L + onStatus("Connected, registering...") + registeredHandled = false + // On-connect commands (identify, etc.) used to fire straight off 001 - + // technically "registered" but too early on plenty of real networks: + // NickServ/services in general aren't guaranteed ready for PRIVMSGs the + // instant 001 lands, which is exactly why real clients (mIRC, HexChat, + // irssi) fire "perform"/on-connect scripts off end-of-MOTD (376/422) + // instead - see runPostRegistration(). This matches that convention, with + // an 8s fallback in case a server never sends either (rare, but some + // minimal/bouncer-style endpoints omit MOTD numerics entirely). + fallbackJob = scope.launch { + delay(8000) + if (!registeredHandled) { + ircDebug(config.id, "no 376/422 within 8s of connecting - running perform+rejoin anyway (fallback)") + runPostRegistration() + } + } + + val reader = BufferedReader(InputStreamReader(sock.getInputStream(), Charsets.UTF_8)) + while (isActive) { + val line = reader.readLine() ?: break + handleRawLine(line) + } + } catch (e: Exception) { + onStatus("Connection error: ${e.message}") + ircDebug(config.id, "connection dropped: ${e.message} - tracked channels at drop time: $joinedChannels") + } finally { + fallbackJob?.cancel() + closeSocketQuietly() + } + + if (wantConnected) { + onStatus("Reconnecting in ${backoffMillis / 1000}s...") + ircDebug(config.id, "reconnecting in ${backoffMillis}ms") + delay(backoffMillis) + backoffMillis = (backoffMillis * 2).coerceAtMost(60_000L) + } + } + } + } + + private fun handleRawLine(line: String) { + // Used to log every single raw line here (RECV: "...") - removed. It was the + // single highest-volume debug line in the app: one per incoming line, every line, + // forever, and println is synchronized/blocking - a real, measurable cost in a + // busy channel, not just noise. The specific 001/376/422 confirmations below are + // once-per-connect and stay. + if (line.startsWith("PING")) { + val token = line.substringAfter("PING ") + send("PONG $token") + return + } + val parsed = parseIrcLine(line) ?: return + + // CTCP requests arrive as a PRIVMSG wrapped in \x01...\x01. VERSION gets + // handled right here so it never shows up in a channel/PM window - real + // clients answer these silently too. + if (parsed.command == "PRIVMSG") { + val text = parsed.params.getOrNull(1) + if (text != null && text.startsWith("") && text.endsWith("") && text.length > 1) { + val ctcpBody = text.removePrefix("").removeSuffix("") + val ctcpCommand = ctcpBody.substringBefore(' ').uppercase() + if (ctcpCommand == "VERSION") { + val replyTo = nickFromPrefix(parsed.prefix) + if (replyTo != null) { + send("NOTICE $replyTo :VERSION It's an irc client, fucko!") + } + return + } + // Other CTCP types (ACTION, PING, etc.) fall through to onLine as normal - + // ACTION in particular still needs to reach ConnectionManager to render as /me. + } + } + + if (parsed.command == "001") { + ircDebug(config.id, "registered (001) - awaiting end-of-MOTD (376/422) before perform+rejoin") + } + if (parsed.command == "376" || parsed.command == "422") { + runPostRegistration() + } + onLine(parsed) + } + + // Runs the on-connect ("perform") commands - NickServ/ChanServ identify, etc. - then + // rejoins every channel we were actually in (the configured auto-join list plus + // anything joined manually mid-session, so a reconnect doesn't forget those). Fired + // off end-of-MOTD rather than bare 001 - see the comment in connect() for why - so + // identify commands land once the session is actually settled enough for services to + // reliably act on them. + private fun runPostRegistration() { + if (registeredHandled) return + registeredHandled = true + // The whole sequence (identify, then rejoin) runs inside this delayed coroutine + // rather than just delaying the identify send - keeping both here preserves "run + // identify before joining anything that might require it" (rejoin used to run + // synchronously right after, which would otherwise now race ahead of a delayed + // identify send and reach the wire first). The delay itself is a few extra hundred + // ms of insurance on top of an already-real event (376/422) rather than a fixed + // guess from connection time - some networks' NickServ genuinely isn't ready to + // process a command in the same instant MOTD ends. + scope.launch(Dispatchers.IO) { + delay(POST_MOTD_IDENTIFY_DELAY_MS) + config.onConnectCommands.forEach { cmd -> + if (cmd.isNotBlank()) send(translateOnConnectCommand(cmd)) + } + val rejoinList = (config.autoJoinChannels + joinedChannels).distinct() + ircDebug(config.id, "post-registration - rejoining: $rejoinList") + rejoinList.forEach { ch -> joinChannel(ch) } + } + } + + fun send(raw: String) { + outbox.trySend(raw) + } + + fun sendMessage(target: String, text: String) { + send("PRIVMSG $target :$text") + } + + fun joinChannel(channel: String) { + joinedChannels.add(channel) + ircDebug(config.id, "joinChannel($channel) - tracked set now: $joinedChannels") + send("JOIN $channel") + } + + fun partChannel(channel: String, reason: String = "") { + joinedChannels.remove(channel) + ircDebug(config.id, "partChannel($channel) - tracked set now: $joinedChannels") + send("PART $channel :$reason") + } + fun kick(channel: String, nick: String, reason: String = "") = send("KICK $channel $nick :$reason") + fun setMode(channel: String, modeChange: String, nick: String) = send("MODE $channel $modeChange $nick") + fun ban(channel: String, mask: String) = send("MODE $channel +b $mask") + + fun disconnect() { + wantConnected = false + scope.launch(Dispatchers.IO) { + try { writer?.println("QUIT :leaving") } catch (_: Exception) {} + readJob?.cancel() + closeSocketQuietly() + } + } + + private fun closeSocketQuietly() { + try { socket?.close() } catch (_: Exception) {} + socket = null + writer = null + } +} diff --git a/src/main/kotlin/irc/IrcLineParser.kt b/src/main/kotlin/irc/IrcLineParser.kt new file mode 100644 index 0000000..d3ed2e3 --- /dev/null +++ b/src/main/kotlin/irc/IrcLineParser.kt @@ -0,0 +1,47 @@ +package irc + +/** A minimally-parsed raw IRC protocol line. */ +data class ParsedLine( + val prefix: String?, // e.g. "nick!user@host" or a server name + val command: String, // e.g. "PRIVMSG", "JOIN", "376", etc. + val params: List // trailing param (after ':') is params.last() if present +) + +fun parseIrcLine(line: String): ParsedLine? { + if (line.isBlank()) return null + var rest = line + var prefix: String? = null + + if (rest.startsWith(":")) { + val spaceIdx = rest.indexOf(' ') + if (spaceIdx == -1) return null + prefix = rest.substring(1, spaceIdx) + rest = rest.substring(spaceIdx + 1) + } + + val trailingSplit = rest.indexOf(" :") + val paramsStr: String + val trailing: String? + if (trailingSplit != -1) { + paramsStr = rest.substring(0, trailingSplit) + trailing = rest.substring(trailingSplit + 2) + } else { + paramsStr = rest + trailing = null + } + + val parts = paramsStr.split(" ").filter { it.isNotEmpty() } + if (parts.isEmpty()) return null + val command = parts[0].uppercase() + val params = parts.drop(1).toMutableList() + if (trailing != null) params.add(trailing) + + return ParsedLine(prefix, command, params) +} + +/** Extracts just the nick portion from an IRC prefix like "nick!user@host". */ +fun nickFromPrefix(prefix: String?): String? { + if (prefix == null) return null + val bangIdx = prefix.indexOf('!') + return if (bangIdx != -1) prefix.substring(0, bangIdx) else prefix +} diff --git a/src/main/kotlin/irc/MircColorParser.kt b/src/main/kotlin/irc/MircColorParser.kt new file mode 100644 index 0000000..5e72e2e --- /dev/null +++ b/src/main/kotlin/irc/MircColorParser.kt @@ -0,0 +1,165 @@ +package irc + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration + +// Standard 16-color mIRC palette (index 0-15). +val mircPalette = listOf( + Color(0xFFFFFFFF), // 0 white + Color(0xFF000000), // 1 black + Color(0xFF00007F), // 2 blue (navy) + Color(0xFF009300), // 3 green + Color(0xFFFF0000), // 4 red + Color(0xFF7F0000), // 5 brown (maroon) + Color(0xFF9C009C), // 6 purple + Color(0xFFFC7F00), // 7 orange + Color(0xFFFFFF00), // 8 yellow + Color(0xFF00FC00), // 9 light green + Color(0xFF00939C), // 10 cyan + Color(0xFF00FFFF), // 11 light cyan + Color(0xFF0000FC), // 12 light blue + Color(0xFFFF00FF), // 13 pink + Color(0xFF7F7F7F), // 14 grey + Color(0xFFD2D2D2) // 15 light grey +) + +private const val BOLD = '' +private const val COLOR = '' +private const val RESET = '' +private const val REVERSE = '' +private const val UNDERLINE = '' + +private const val ITALIC = '' + +private val urlRegex = Regex("""https?://\S+""") +private val urlColor = Color(0xFF4EA1FF) + +private val imageUrlRegex = Regex("""https?://[^\s]+""") +private val imageExtensions = setOf("jpg", "jpeg", "png", "gif", "webp", "bmp") + +/** + * Extracts URLs from raw message text that look like they point at an image, + * based on the file extension in the path (ignoring query string/fragment). + */ +fun extractImageUrls(text: String): List { + return imageUrlRegex.findAll(text).map { it.value }.filter { url -> + val cleanUrl = url.substringBefore('?').substringBefore('#') + val ext = cleanUrl.substringAfterLast('.', "").lowercase() + ext in imageExtensions + }.toList() +} + +/** + * Parses a raw IRC line (which may contain mIRC control codes) into an AnnotatedString + * ready to render in Compose. baseColor is applied where no explicit foreground is set, + * so it follows the user's chosen black/white text setting. + */ +fun parseMircToAnnotatedString(raw: String, baseColor: Color): AnnotatedString { + val builder = AnnotatedString.Builder() + + var bold = false + var underline = false + var italic = false + var reverse = false + var fg: Color? = null + var bg: Color? = null + + fun currentStyle(): SpanStyle { + val effectiveFg = if (reverse) (bg ?: baseColor) else (fg ?: baseColor) + val effectiveBg = if (reverse) (fg ?: baseColor) else bg + return SpanStyle( + color = effectiveFg, + background = effectiveBg ?: Color.Unspecified, + fontWeight = if (bold) FontWeight.Bold else FontWeight.Normal, + fontStyle = if (italic) FontStyle.Italic else FontStyle.Normal, + textDecoration = if (underline) TextDecoration.Underline else TextDecoration.None + ) + } + + var i = 0 + var runStart = 0 + + fun flush(endExclusive: Int) { + if (endExclusive > runStart) { + builder.withStyle(currentStyle()) { + append(raw.substring(runStart, endExclusive)) + } + } + } + + fun readColorDigits(startIdx: Int): Pair { + // Reads up to 2 digits starting at startIdx, returns (colorIndexOrNull, nextIndex) + var idx = startIdx + var numStr = "" + while (idx < raw.length && raw[idx].isDigit() && numStr.length < 2) { + numStr += raw[idx] + idx++ + } + if (numStr.isEmpty()) return Pair(null, startIdx) + val n = numStr.toInt().coerceIn(0, 15) + return Pair(n, idx) + } + + while (i < raw.length) { + when (raw[i]) { + BOLD -> { + flush(i); bold = !bold; i++; runStart = i + } + UNDERLINE -> { + flush(i); underline = !underline; i++; runStart = i + } + ITALIC -> { + flush(i); italic = !italic; i++; runStart = i + } + REVERSE -> { + flush(i); reverse = !reverse; i++; runStart = i + } + RESET -> { + flush(i) + bold = false; underline = false; italic = false; reverse = false; fg = null; bg = null + i++; runStart = i + } + COLOR -> { + flush(i) + i++ + val (fgIdx, nextI) = readColorDigits(i) + i = nextI + if (fgIdx == null) { + fg = null; bg = null + } else { + fg = mircPalette[fgIdx] + if (i < raw.length && raw[i] == ',') { + val (bgIdx, nextI2) = readColorDigits(i + 1) + if (bgIdx != null) { + bg = mircPalette[bgIdx] + i = nextI2 + } + } + } + runStart = i + } + else -> i++ + } + } + flush(raw.length) + + val parsed = builder.toAnnotatedString() + val matches = urlRegex.findAll(parsed.text).toList() + if (matches.isEmpty()) return parsed + + // Layer link styling/annotations on top of the existing mIRC-colored string rather + // than replacing it, so control codes elsewhere in the line are unaffected. + val linked = AnnotatedString.Builder(parsed) + matches.forEach { match -> + val start = match.range.first + val end = match.range.last + 1 + linked.addStyle(SpanStyle(color = urlColor, textDecoration = TextDecoration.Underline), start, end) + linked.addStringAnnotation(tag = "URL", annotation = match.value, start = start, end = end) + } + return linked.toAnnotatedString() +} diff --git a/src/main/kotlin/ui/ChannelScreen.kt b/src/main/kotlin/ui/ChannelScreen.kt new file mode 100644 index 0000000..d7767ad --- /dev/null +++ b/src/main/kotlin/ui/ChannelScreen.kt @@ -0,0 +1,1077 @@ +package ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.window.WindowDraggableArea +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.ClickableText +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.toComposeImageBitmap +import androidx.compose.ui.input.key.* +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.DialogWindow +import androidx.compose.ui.window.WindowScope +import androidx.compose.ui.window.WindowState +import androidx.compose.ui.window.rememberDialogState +import data.ChannelBuffer +import data.ChannelUser +import data.MessageKind +import data.WindowDisplaySettings +import irc.extractImageUrls +import irc.mircPalette +import irc.parseMircToAnnotatedString +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import java.awt.Desktop +import java.net.URI +import java.net.URL +import java.text.SimpleDateFormat +import java.util.* +import kotlin.math.floor + +// TEMP DEBUG (see task: auto-scroll from-scratch audit) - remove once confirmed fixed. +// label identifies which window's log line this is, so multi-window repros are legible. +private fun ircUiDebug(label: String, msg: String) { + println("[ui-debug][$label] $msg") +} + +// Single-space separator between the padded nick column and the message body. Previously +// a 2-space trailing gap on CHAT lines (on top of the padEnd() column padding itself), +// which read as excessive once a shorter nick was padded out to match a much longer one +// in the same channel - one space is enough to visually separate nick from body. +private const val NICK_COLUMN_GAP = " " + +// Fixed nick-column width in characters (tuned to look right at the default font size). +// This used to be computed from the longest nick actually present in the channel's user +// list, which broke badly the moment anyone had a deliberately oversized nick (IRC allows +// up to ~30 chars on plenty of networks, and it's a well-known way to grief layouts like +// this) - one wide nick would blow out the column for every single line in the channel. +// A fixed cap means the column width can never depend on who's present. +private const val NICK_COLUMN_WIDTH = 16 + +// Pads a nick to exactly NICK_COLUMN_WIDTH characters, or - for a nick that's actually +// longer than the column - truncates it to a display-only ellipsis form of exactly that +// width. This is purely cosmetic: everywhere a nick is used functionally (tab-complete, +// /msg, the Kick/Ban/Op context menu, mention-highlight matching against the message body) +// keeps reading the real, untruncated ChannelUser.nick / msg.sender - only this rendered +// Text string is shortened. +private fun displayNick(nick: String): String = + if (nick.length <= NICK_COLUMN_WIDTH) nick.padEnd(NICK_COLUMN_WIDTH) + else nick.take(NICK_COLUMN_WIDTH - 3) + "..." + +// Tracks an in-progress nick tab-completion so a repeated Tab press (with the +// input still exactly as this completion left it) cycles to the next match +// instead of redoing the same one. +private data class TabCompletionState( + val wordStart: Int, + val matches: List, + val index: Int, + val textAfter: String, + val cursorAfter: Int +) + +// Best-effort "open this in the default browser" - there's no Android Intent system on +// desktop, so java.awt.Desktop is the portable equivalent. Silently no-ops if the +// platform/desktop environment doesn't support it (e.g. some minimal Linux setups). +private fun openUrlInBrowser(url: String) { + try { + if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) { + Desktop.getDesktop().browse(URI(url)) + } + } catch (e: Exception) { + // Nothing sensible to do if the OS has no browser association. + } +} + +@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class) +@Composable +fun WindowScope.ChannelScreen( + windowState: WindowState, + onCloseWindow: () -> Unit, + channelName: String, + buffer: ChannelBuffer?, + displaySettings: WindowDisplaySettings, + colorScheme: ColorScheme, + ownNick: String?, + onSend: (String) -> Unit, + onKick: (String) -> Unit, + onBan: (String) -> Unit, + onOp: (String) -> Unit, + onDeop: (String) -> Unit, + onAlphaChange: (Float) -> Unit, + onTextColorChange: (Int) -> Unit, + onFontSizeChange: (Float) -> Unit, + onShowUserListChange: (Boolean) -> Unit, + onNickColorsToggle: (Boolean) -> Unit, + onMentionHighlightColorChange: (Int) -> Unit, + onShowImagePreviewsChange: (Boolean) -> Unit +) { + var input by remember { mutableStateOf(TextFieldValue("")) } + var showSettings by remember { mutableStateOf(false) } + var contextMenuUser by remember { mutableStateOf(null) } + var showColorPicker by remember { mutableStateOf(false) } + var tabState by remember { mutableStateOf(null) } + val history = remember { mutableStateListOf() } + var historyIndex by remember { mutableStateOf(-1) } + var draftBeforeHistory by remember { mutableStateOf("") } + val inputFocusRequester = remember { FocusRequester() } + val coroutineScope = rememberCoroutineScope() + + // Reading java.awt.Toolkit's system clipboard is a blocking native (JNI/AWT) call, and + // on Windows it can genuinely hang - a well-documented class of bug where the OS-level + // clipboard lock doesn't release promptly, particularly right after another app (a + // password manager, for instance) just wrote to it. BasicTextField's own built-in + // Ctrl+V handling calls this SAME clipboard API synchronously on the UI thread, so if + // it hangs, the whole app hangs with it - not just this text field. + // + // The fix here is really the Dispatchers.IO hop, not the timeout: a single blocking + // Java call has no suspension point for withTimeoutOrNull's cancellation to land on, + // so if the native call is truly and permanently stuck, this coroutine (and its one + // IO-pool thread) stays parked right along with it - withTimeoutOrNull only actually + // saves us for a call that's merely slow and eventually returns. Either way, none of + // that is on the UI thread, so the app itself never freezes - worst case is a silently + // failed paste and one leaked background thread, not a hung window. + fun pasteFromClipboardSafely() { + coroutineScope.launch { + val pasted = withContext(Dispatchers.IO) { + try { + withTimeoutOrNull(2000) { + java.awt.Toolkit.getDefaultToolkit().systemClipboard + .getData(java.awt.datatransfer.DataFlavor.stringFlavor) as? String + } + } catch (e: Exception) { + null + } + } + if (!pasted.isNullOrEmpty()) { + // Same shape as insertControlChar/insertColorCode below: replace the + // current selection (or insert at the cursor if nothing's selected). + // Newlines are flattened to spaces - this is a singleLine field. + val flat = pasted.replace("\r\n", " ").replace("\n", " ").replace("\r", " ") + val text = input.text + val sel = input.selection + val newText = text.substring(0, sel.start) + flat + text.substring(sel.end) + val newCursor = sel.start + flat.length + input = TextFieldValue(newText, androidx.compose.ui.text.TextRange(newCursor)) + } + } + } + + // Inserts a raw mIRC/IRCv3 formatting control char at the current cursor + // position (or wraps the selection, if any), same behavior as toggling + // bold/italic in a real mIRC client - it's a toggle marker, not a wrapper tag. + fun insertControlChar(char: Char) { + val text = input.text + val sel = input.selection + val newText = text.substring(0, sel.start) + char + text.substring(sel.end) + val newCursor = sel.start + 1 + input = TextFieldValue(newText, androidx.compose.ui.text.TextRange(newCursor)) + } + + fun insertColorCode(fg: Int, bg: Int?) { + val code = "" + fg.toString().padStart(2, '0') + (bg?.let { "," + it.toString().padStart(2, '0') } ?: "") + val text = input.text + val sel = input.selection + val newText = text.substring(0, sel.start) + code + text.substring(sel.end) + val newCursor = sel.start + code.length + input = TextFieldValue(newText, androidx.compose.ui.text.TextRange(newCursor)) + } + + // Tab-completes the nick fragment before the cursor against buffer.users. + // A repeated Tab press against the exact text/cursor state this function + // last produced cycles to the next match; anything else (fresh typing, + // moved cursor) starts a new completion from scratch. + fun tryTabComplete(): Boolean { + val text = input.text + val cursor = input.selection.start + val state = tabState + + if (state != null && text == state.textAfter && cursor == state.cursorAfter) { + val nextIndex = (state.index + 1) % state.matches.size + val nick = state.matches[nextIndex] + val completion = "$nick " + val newText = text.substring(0, state.wordStart) + completion + text.substring(state.cursorAfter) + val newCursor = state.wordStart + completion.length + input = TextFieldValue(newText, androidx.compose.ui.text.TextRange(newCursor)) + tabState = state.copy(index = nextIndex, textAfter = newText, cursorAfter = newCursor) + return true + } + + var wordStart = cursor + while (wordStart > 0 && !text[wordStart - 1].isWhitespace()) wordStart-- + val partial = text.substring(wordStart, cursor) + val matches = (buffer?.users ?: emptyList()).map { it.nick } + .filter { it.startsWith(partial, ignoreCase = true) } + + if (matches.isEmpty()) { + tabState = null + return false + } + + val nick = matches[0] + val completion = "$nick " + val newText = text.substring(0, wordStart) + completion + text.substring(cursor) + val newCursor = wordStart + completion.length + input = TextFieldValue(newText, androidx.compose.ui.text.TextRange(newCursor)) + tabState = TabCompletionState(wordStart, matches, 0, newText, newCursor) + return true + } + + // Pushes a sent line onto the up/down-arrow recall history (skipping an exact repeat + // of the last entry) and resets navigation back to "not browsing history". + fun recordHistory(text: String) { + if (history.isEmpty() || history.last() != text) { + history.add(text) + } + historyIndex = -1 + } + + fun sendCurrentInput() { + if (input.text.isNotBlank()) { + recordHistory(input.text) + onSend(input.text) + input = TextFieldValue("") + } + } + + fun isMention(text: String): Boolean { + val nick = ownNick + if (nick.isNullOrBlank()) return false + return Regex("(?i)\\b${Regex.escape(nick)}\\b").containsMatchIn(text) + } + + 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() + + // 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. + LaunchedEffect(listState) { + snapshotFlow { + listState.layoutInfo.visibleItemsInfo.firstOrNull()?.index + }.collect { firstVisible -> + stickToBottom = firstVisible == null || firstVisible <= 1 + } + } + + // 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. + // + // This needs BOTH of two properties that earlier versions of this effect only ever had + // one of at a time: + // + // 1) The trigger has to be something that changes on literally every new message, cap or + // no cap. A version of this keyed on listState.layoutInfo.totalItemsCount got that + // wrong: 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 the count + // - and totalItemsCount with it - stops changing forever past that point, and the + // effect silently stopped firing for any channel busy enough to fill its scrollback. + // msg.seq is monotonically increasing and never repeats or saturates, so it's used as + // the LaunchedEffect's own key here instead - Compose relaunches the effect (with a + // fresh closure over the current `stickToBottom`/`listState`) every time it changes, + // which also sidesteps a staleness trap a `snapshotFlow { messages.lastOrNull() } would + // fall into: `messages` is a plain local val recomputed on every recomposition, not a + // live read through a stable remembered object like `listState`, so a *persisted* + // snapshotFlow closure capturing it would only ever see the value from the first + // composition it ran in. + // + // 2) The actual scrollToItem(0) call has to wait until the LazyColumn's own layout pass + // has genuinely caught up to that new item before it runs. This part was confirmed by + // hand, live, after dropping it: keying the relaunch on msg.seq and calling + // scrollToItem(0) immediately (no wait) reintroduced the exact one-message-behind lag + // this file's history already describes - sending several messages in a row left the + // scroll position permanently one message short, each new send only revealing the + // PREVIOUS one. Composition having the new item (which msg.seq as a key does guarantee) + // is not the same thing as the LazyColumn's layout pass having processed it yet - + // scrollToItem(0) called too early resolves against however many items layout still + // thinks exist, which is one short, and nothing re-corrects it afterwards. Waiting for + // layoutInfo.totalItemsCount to actually reach the expected count - which, unlike using + // it as the trigger, works fine even at the cap, since "count" here just means "layout + // has processed at least this many items so far", not "count changed" - closes that + // window without giving up cap-immunity. + val newestSeq = messages.lastOrNull()?.seq + val expectedItemCount = messages.size + LaunchedEffect(newestSeq) { + if (newestSeq != null && stickToBottom) { + snapshotFlow { listState.layoutInfo.totalItemsCount } + .first { it >= expectedItemCount } + listState.scrollToItem(0) + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + inputFocusRequester.requestFocus() + } + .background(bgColor) + ) { + Column(modifier = Modifier.fillMaxSize()) { + // Title bar: channel name + settings gear. Deliberately plain, no theming. + // undecorated windows have no native OS title bar, so this strip both displays + // the title and doubles as the drag handle to move the window (only this Row, + // not the whole window content, so it doesn't steal clicks from the rest of + // the UI) and hosts the minimize/close buttons the OS chrome would otherwise give. + // WindowDraggableArea is Compose Desktop's own drag-to-move implementation - + // a hand-rolled pointerInput/detectDragGestures version of this was jittery + // because manually accumulating dragAmount into windowState.position fights + // with the OS's own window-move handling; WindowDraggableArea talks to that + // directly instead of reimplementing it. + WindowDraggableArea(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 6.dp, vertical = 2.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(channelName, color = textColor, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.labelSmall) + Row { + IconButton(onClick = { onShowUserListChange(!displaySettings.showUserList) }, modifier = Modifier.size(28.dp)) { + Icon(Icons.Default.Person, contentDescription = "Toggle user list", tint = textColor, modifier = Modifier.size(16.dp)) + } + IconButton(onClick = { showSettings = !showSettings }, modifier = Modifier.size(28.dp)) { + Icon(Icons.Default.Settings, contentDescription = "Window settings", tint = textColor, modifier = Modifier.size(16.dp)) + } + IconButton(onClick = { windowState.isMinimized = true }, modifier = Modifier.size(28.dp)) { + Box( + modifier = Modifier + .size(width = 10.dp, height = 2.dp) + .background(textColor) + ) + } + IconButton(onClick = onCloseWindow, modifier = Modifier.size(28.dp)) { + Icon(Icons.Default.Close, contentDescription = "Close window", tint = textColor, modifier = Modifier.size(16.dp)) + } + } + } + } + + if (showSettings) { + // A real, separate top-level window (DialogWindow), not an AlertDialog/Popup + // embedded in this window's own layout. This used to be an inline Column + // squeezed directly into this Column below the title bar, sharing height with + // the message list and input bar - heightIn(max=280.dp) plus an internal + // scroll made every setting reachable once you'd already scrolled to it, but + // the panel (and the rest of this window's content fighting it for space) was + // still hard-clipped to whatever height this channel window itself currently + // had, e.g. a window resized shorter than ~280dp could clip the panel outright + // no matter how much its own content scrolled. A DialogWindow is a genuinely + // separate OS window sized on its own terms, so it can never be cut off by + // this channel window's current size. MaterialTheme/Surface are reapplied here + // because a DialogWindow is its own composition root - it doesn't inherit the + // MaterialTheme this window's own content sits under in ChannelWindow.kt. + DialogWindow( + onCloseRequest = { showSettings = false }, + title = "$channelName settings", + state = rememberDialogState(size = DpSize(340.dp, 560.dp)), + resizable = true + ) { + MaterialTheme(colorScheme = colorScheme) { + Surface { + WindowSettingsPanel( + displaySettings = displaySettings, + textColor = textColor, + onAlphaChange = onAlphaChange, + onTextColorChange = onTextColorChange, + onFontSizeChange = onFontSizeChange, + onNickColorsToggle = onNickColorsToggle, + onMentionHighlightColorChange = onMentionHighlightColorChange, + onShowImagePreviewsChange = onShowImagePreviewsChange + ) + } + } + } + } + + // 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)) + } + } + + // User list (tap and hold for OP actions) + if (displaySettings.showUserList && !buffer?.users.isNullOrEmpty()) { + LazyColumn( + modifier = Modifier + .width(140.dp) + .fillMaxHeight() + .padding(4.dp) + ) { + items(buffer!!.users) { user -> + Text( + text = (if (user.isOp) "@" else if (user.isVoice) "+" else "") + user.nick, + color = textColor, + fontFamily = FontFamily.Monospace, + fontSize = displaySettings.fontSize.sp, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp) + .combinedClickable( + onClick = { + val prefill = "/msg ${user.nick} " + input = TextFieldValue(prefill, androidx.compose.ui.text.TextRange(prefill.length)) + inputFocusRequester.requestFocus() + }, + onLongClick = { contextMenuUser = user } + ) + ) + } + } + } + } + + // Input bar + Row( + modifier = Modifier + .fillMaxWidth() + .padding(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .weight(1f) + .heightIn(min = 32.dp, max = 40.dp) + .padding(vertical = 2.dp), + contentAlignment = Alignment.CenterStart + ) { + BasicTextField( + value = input, + onValueChange = { input = it }, + singleLine = true, + textStyle = androidx.compose.ui.text.TextStyle( + fontFamily = FontFamily.Monospace, + color = textColor, + fontSize = displaySettings.fontSize.sp + ), + cursorBrush = SolidColor(textColor), + modifier = Modifier + .fillMaxWidth() + .focusRequester(inputFocusRequester) + .onPreviewKeyEvent { event -> + // Hardware-keyboard shortcuts: Ctrl+K color picker, Ctrl+B bold, + // Ctrl+I italic, Ctrl+V paste (see pasteFromClipboardSafely - + // intercepted and handled ourselves rather than left to + // BasicTextField's own built-in paste, which hangs the whole + // app if the native clipboard read blocks). + if (event.type == KeyEventType.KeyDown && event.isCtrlPressed) { + when (event.key) { + Key.K -> { showColorPicker = true; true } + Key.V -> { pasteFromClipboardSafely(); true } + Key.B -> { insertControlChar(''); true } + Key.I -> { insertControlChar(''); true } + else -> false + } + } else if (event.type == KeyEventType.KeyDown && event.key == Key.Enter && !event.isCtrlPressed) { + sendCurrentInput() + true + } else if (event.type == KeyEventType.KeyDown && event.key == Key.Tab && !event.isCtrlPressed) { + tryTabComplete() + } else if (event.type == KeyEventType.KeyDown && event.key == Key.DirectionUp) { + if (history.isEmpty()) { + false + } else { + if (historyIndex == -1) draftBeforeHistory = input.text + historyIndex = (historyIndex + 1).coerceAtMost(history.size - 1) + val entry = history[history.size - 1 - historyIndex] + input = TextFieldValue(entry, androidx.compose.ui.text.TextRange(entry.length)) + true + } + } else if (event.type == KeyEventType.KeyDown && event.key == Key.DirectionDown) { + if (historyIndex == -1) { + false + } else { + historyIndex-- + if (historyIndex < 0) { + input = TextFieldValue(draftBeforeHistory, androidx.compose.ui.text.TextRange(draftBeforeHistory.length)) + } else { + val entry = history[history.size - 1 - historyIndex] + input = TextFieldValue(entry, androidx.compose.ui.text.TextRange(entry.length)) + } + true + } + } else false + }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + keyboardActions = KeyboardActions(onSend = { sendCurrentInput() }) + ) + } + } + } + + contextMenuUser?.let { user -> + OpActionMenu( + user = user, + onDismiss = { contextMenuUser = null }, + onKick = { onKick(user.nick); contextMenuUser = null }, + onBan = { onBan(user.nick); contextMenuUser = null }, + onOp = { onOp(user.nick); contextMenuUser = null }, + onDeop = { onDeop(user.nick); contextMenuUser = null } + ) + } + + if (showColorPicker) { + MircColorPickerDialog( + onDismiss = { showColorPicker = false }, + onPick = { fg, bg -> + insertColorCode(fg, bg) + showColorPicker = false + } + ) + } + } +} + +// In-memory LRU cache of decoded image bitmaps, keyed by URL, so scrolling a channel +// with image previews on doesn't re-fetch/re-decode images that already loaded. +private object ImageCache { + private const val MAX_ENTRIES = 100 + private val map = object : LinkedHashMap(16, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = + size > MAX_ENTRIES + } + + @Synchronized + fun get(url: String): ImageBitmap? = map[url] + + @Synchronized + fun contains(url: String): Boolean = map.containsKey(url) + + @Synchronized + fun put(url: String, bitmap: ImageBitmap?) { + map[url] = bitmap + } +} + +// Desktop equivalent of Coil's AsyncImage: fetches the URL's bytes off the UI thread, +// decodes with Skia, and caches the result so re-composition/scrolling is free after +// the first load. Renders nothing if the fetch/decode fails. +@Composable +private fun UrlImagePreview(url: String, modifier: Modifier, onClick: () -> Unit) { + var bitmap by remember(url) { mutableStateOf(ImageCache.get(url)) } + + LaunchedEffect(url) { + if (!ImageCache.contains(url)) { + val loaded = withContext(Dispatchers.IO) { + try { + val bytes = URL(url).readBytes() + org.jetbrains.skia.Image.makeFromEncoded(bytes).toComposeImageBitmap() + } catch (e: Exception) { + null + } + } + ImageCache.put(url, loaded) + bitmap = loaded + } + } + + val bmp = bitmap + if (bmp != null) { + Image( + bitmap = bmp, + contentDescription = null, + modifier = modifier.clickable(onClick = onClick), + contentScale = ContentScale.Fit + ) + } +} + +// Standard 16-color mIRC palette swatches, same indices the wire protocol uses +// (FG,BG), so picking a swatch here inserts the exact code mIRC/HexChat/etc send. +private val mircSwatches = mircPalette.mapIndexed { index, color -> index to color } + +// Deterministic per-nick color so the same nick always renders the same color within a +// session, without needing to track color assignments anywhere. +private val nickColorPalette = listOf( + Color(0xFFFF6B6B), Color(0xFF4ECDC4), Color(0xFFFFD93D), Color(0xFF95E1D3), + Color(0xFFF38181), Color(0xFFAA96DA), Color(0xFFFCBAD3), Color(0xFFA8D8EA), + Color(0xFFFFAAA5), Color(0xFF88D8B0) +) + +private fun colorForNick(nick: String): Color = + nickColorPalette[(nick.hashCode().and(0x7FFFFFFF)) % nickColorPalette.size] + +@Composable +private fun MircColorPickerDialog(onDismiss: () -> Unit, onPick: (fg: Int, bg: Int?) -> Unit) { + var selectedFg by remember { mutableStateOf(null) } + var selectedBg by remember { mutableStateOf(null) } + var pickingBg by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(if (pickingBg) "Background (optional)" else "Text color") }, + text = { + Column { + FlowSwatchGrid( + selected = if (pickingBg) selectedBg else selectedFg, + onSelect = { idx -> if (pickingBg) selectedBg = idx else selectedFg = idx } + ) + Spacer(Modifier.height(8.dp)) + if (!pickingBg) { + TextButton(onClick = { pickingBg = true }, enabled = selectedFg != null) { + Text("Next: pick background (optional) →") + } + } + } + }, + confirmButton = { + TextButton( + onClick = { if (selectedFg != null) onPick(selectedFg!!, selectedBg) }, + enabled = selectedFg != null + ) { Text("Insert") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + } + ) +} + +// swatchColor lets a caller show something other than the raw palette color (e.g. the +// mention-highlight picker needs to preview the translucent tint that actually gets +// applied, not the opaque swatch color, or the "selected" square looks nothing like the +// effect it produces). Defaults to the identity transform for every other caller. +@Composable +private fun FlowSwatchGrid(selected: Int?, onSelect: (Int) -> Unit, swatchColor: (Color) -> Color = { it }) { + Column { + mircSwatches.chunked(4).forEach { row -> + Row { + row.forEach { (idx, color) -> + // Both border states used to be Color.Black, which is invisible against + // this app's black window background/AlertDialog surface - the + // "currently selected" square was indistinguishable from the rest. + // White/gray are visible against that background in both states. + val borderColor = if (selected == idx) Color.White else Color.Gray + Box( + modifier = Modifier + .padding(2.dp) + .size(36.dp) + .background(swatchColor(color)) + .border(if (selected == idx) 3.dp else 1.dp, borderColor) + .clickableNoRipple { onSelect(idx) } + ) + } + } + } + } +} + +@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) +@Composable +private fun Modifier.clickableNoRipple(onClick: () -> Unit): Modifier = this.then( + Modifier.combinedClickable(onClick = onClick, onLongClick = {}) +) + +// The alpha byte applied to whichever swatch is picked as the mention-highlight tint. +private const val MENTION_HIGHLIGHT_ALPHA_BYTE = 0x40 + +// Reuses the same mIRC swatch grid as Ctrl+K, but picks a single color and converts it +// to a translucent (~25% alpha) ARGB int suitable for a message-row background tint. +// currentArgb is the color actually in effect, so the grid can (a) show which swatch is +// currently selected - it used to always pass selected=null, so nothing was ever +// highlighted regardless of what was active - and (b) render every swatch at the same +// translucent alpha that selecting it would actually apply, instead of full-opacity +// squares that look nothing like the ~25%-alpha tint that ends up on messages. +@Composable +private fun MentionColorPickerDialog(currentArgb: Int, onDismiss: () -> Unit, onPick: (Int) -> Unit) { + val currentRgb = currentArgb and 0x00FFFFFF + val selectedIdx = mircSwatches.firstOrNull { (_, color) -> (color.toArgb() and 0x00FFFFFF) == currentRgb }?.first + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Mention highlight color") }, + text = { + FlowSwatchGrid( + selected = selectedIdx, + onSelect = { idx -> + val rgb = mircSwatches.first { it.first == idx }.second.toArgb() + val translucentArgb = (MENTION_HIGHLIGHT_ALPHA_BYTE shl 24) or (rgb and 0x00FFFFFF) + onPick(translucentArgb) + onDismiss() + }, + swatchColor = { color -> color.copy(alpha = MENTION_HIGHLIGHT_ALPHA_BYTE / 255f) } + ) + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text("Close") } + } + ) +} + +@Composable +private fun WindowSettingsPanel( + displaySettings: WindowDisplaySettings, + textColor: Color, + onAlphaChange: (Float) -> Unit, + onTextColorChange: (Int) -> Unit, + onFontSizeChange: (Float) -> Unit, + onNickColorsToggle: (Boolean) -> Unit, + onMentionHighlightColorChange: (Int) -> Unit, + onShowImagePreviewsChange: (Boolean) -> Unit +) { + var showMentionColorPicker by remember { mutableStateOf(false) } + val sliderColors = SliderDefaults.colors( + thumbColor = Color.White, + activeTrackColor = Color.White, + inactiveTrackColor = Color.White.copy(alpha = 0.3f) + ) + // This now renders inside its own DialogWindow (see ChannelScreen) rather than being + // squeezed inline into the channel window's own layout, so it's free to fill whatever + // size that dialog window actually has. The internal scroll stays as a safety net for + // whenever the user resizes that window smaller than the content needs, rather than as + // the only thing standing between a setting and being permanently unreachable. + Column( + modifier = Modifier + .fillMaxSize() + .padding(8.dp) + .verticalScroll(rememberScrollState()) + ) { + Text("Background transparency", color = textColor, style = MaterialTheme.typography.bodySmall) + Slider( + value = displaySettings.backgroundAlpha, + onValueChange = onAlphaChange, + valueRange = 0f..1f, + colors = sliderColors + ) + Spacer(Modifier.height(4.dp)) + Text("Font size", color = textColor, style = MaterialTheme.typography.bodySmall) + Slider( + value = displaySettings.fontSize, + onValueChange = onFontSizeChange, + valueRange = 10f..24f, + colors = sliderColors + ) + Spacer(Modifier.height(4.dp)) + Text("Text color:", color = textColor, style = MaterialTheme.typography.bodySmall) + FlowSwatchGrid( + selected = displaySettings.textColorMircIndex, + onSelect = onTextColorChange + ) + Spacer(Modifier.height(4.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Nick colors", color = textColor, style = MaterialTheme.typography.bodySmall) + Spacer(Modifier.width(8.dp)) + Switch( + checked = displaySettings.nickColorsEnabled, + onCheckedChange = onNickColorsToggle + ) + } + Spacer(Modifier.height(4.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Mention highlight color:", color = textColor, style = MaterialTheme.typography.bodySmall) + Spacer(Modifier.width(8.dp)) + Box( + modifier = Modifier + .size(24.dp) + .background(Color(displaySettings.mentionHighlightColorArgb)) + .border(1.dp, Color.White) + .clickableNoRipple { showMentionColorPicker = true } + ) + } + Spacer(Modifier.height(4.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Show image previews", color = textColor, style = MaterialTheme.typography.bodySmall) + Spacer(Modifier.width(8.dp)) + Switch( + checked = displaySettings.showImagePreviews, + onCheckedChange = onShowImagePreviewsChange + ) + } + if (showMentionColorPicker) { + MentionColorPickerDialog( + currentArgb = displaySettings.mentionHighlightColorArgb, + onDismiss = { showMentionColorPicker = false }, + onPick = onMentionHighlightColorChange + ) + } + } +} + +@Composable +private fun OpActionMenu( + user: ChannelUser, + onDismiss: () -> Unit, + onKick: () -> Unit, + onBan: () -> Unit, + onOp: () -> Unit, + onDeop: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(user.nick) }, + text = { + Column { + TextButton(onClick = onKick) { Text("Kick") } + TextButton(onClick = onBan) { Text("Ban") } + if (user.isOp) { + TextButton(onClick = onDeop) { Text("De-op") } + } else { + TextButton(onClick = onOp) { Text("Op") } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text("Close") } + } + ) +} diff --git a/src/main/kotlin/ui/ChannelWindow.kt b/src/main/kotlin/ui/ChannelWindow.kt new file mode 100644 index 0000000..a284732 --- /dev/null +++ b/src/main/kotlin/ui/ChannelWindow.kt @@ -0,0 +1,170 @@ +package ui + +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +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.window.Window +import androidx.compose.ui.window.rememberWindowState +import data.AliasRepository +import data.BufferKey +import data.WindowDisplaySettings +import data.WindowPrefsRepository +import data.isChannelName +import data.storageId +import irc.ConnectionManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +/** + * One of these is composed per open buffer key (see Main.kt) - each is a real, independent + * top-level OS window, same intent as ChannelWindowActivity.kt's per-channel Activity on + * Android, just using Compose Desktop's native multi-Window() support instead of the + * Activity/freeform-window plumbing Android needed. + */ +@Composable +fun ChannelWindow( + bufferKey: BufferKey, + prefsRepo: WindowPrefsRepository, + colorScheme: ColorScheme, + onOpenBuffer: (BufferKey) -> Unit, + onCloseSelf: () -> Unit, + onQuitApp: () -> Unit +) { + val serverId = bufferKey.serverId + val channelName = bufferKey.name + val windowTitle = if (isChannelName(channelName)) channelName else "PM: $channelName" + val scope = rememberCoroutineScope() + + val displaySettings by prefsRepo.settingsFor(bufferKey.storageId()).collectAsState() + // A previous pass replaced this with a hand-rolled Flow.map{}.distinctUntilChanged() + // pipeline scoped per-window, reasoning that collecting the WHOLE map here (as every + // open window does) causes every window to recompose on every single incoming + // message anywhere, not just its own - true, but that's a performance concern, and + // the auto-scroll bug that pipeline was ALSO supposed to help fix persisted across + // multiple attempts regardless. A hand-chained Flow pipeline is real reactive-system + // surface area living outside Compose's own snapshot system, bridged back in via + // collectAsState - exactly the kind of thing that's hard to fully verify by reading + // alone. Reverted to the plain, standard, well-tested collectAsState() + derivedStateOf + // pattern: still avoids recomposing anything that only reads `buffer` when an + // unrelated buffer changes (derivedStateOf's whole purpose), but goes through + // Compose's own primitives end to end instead of a separate hand-built one. + val buffers by ConnectionManager.buffers.collectAsState() + 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() + + // undecorated + transparent trade the native OS title bar for real per-pixel window + // transparency (Windows only honors transparency on undecorated windows) - ChannelScreen + // supplies its own title bar with minimize/close/drag/resize to make up for what the + // OS chrome would otherwise provide. No Surface wrapper here (M3 Surface paints an + // opaque background) - ChannelScreen already paints its own alpha-aware background. + Window( + onCloseRequest = onCloseSelf, + title = windowTitle, + icon = painterResource("icon.png"), + state = windowState, + undecorated = true, + transparent = true, + resizable = true + ) { + MaterialTheme(colorScheme = colorScheme) { + ChannelScreen( + windowState = windowState, + onCloseWindow = onCloseSelf, + channelName = windowTitle, + buffer = buffer, + displaySettings = displaySettings, + colorScheme = colorScheme, + ownNick = ownNick, + onSend = { text -> + if (text.startsWith("/")) { + val withoutSlash = text.substring(1) + val spaceIdx = withoutSlash.indexOf(' ') + val command = (if (spaceIdx >= 0) withoutSlash.substring(0, spaceIdx) else withoutSlash).uppercase() + val rawArgs = if (spaceIdx >= 0) withoutSlash.substring(spaceIdx + 1) else "" + val args = rawArgs.trim() + + // App-level commands are checked before any IRC command handling, + // and identically in every window (global, not per-server/channel): + // /reload is reserved and can't be shadowed by an aliases.txt entry + // (AliasRepository never loads one named "reload"); alias lookup + // comes next so a matching alias runs locally instead of hitting + // the built-in ME/NICK/PART/etc. handling or a raw passthrough. + if (command == "RELOAD") { + val count = AliasRepository.reload() + ConnectionManager.addSystemMessage(serverId, channelName, "Aliases reloaded ($count aliases loaded)") + } else if (AliasRepository.tryRunAlias(command, rawArgs)) { + // Alias handled locally (opened a path/url or launched a process) - nothing sent to the server. + } else { + when (command) { + "ME" -> ConnectionManager.sendChannelAction(serverId, channelName, args) + "NICK" -> ConnectionManager.sendRaw(serverId, "NICK $args") + "PART", "EXIT", "CLOSE" -> { + val target = args.ifBlank { channelName } + ConnectionManager.connectionFor(serverId)?.partChannel(target, "leaving") + if (args.isBlank() || target == channelName) onCloseSelf() + } + "QUIT" -> { + if (rawArgs.isBlank()) { + ConnectionManager.disconnectAll() + onQuitApp() + } else { + ConnectionManager.sendChannelMessage(serverId, channelName, rawArgs) + } + } + "JOIN" -> { + ConnectionManager.sendRaw(serverId, "JOIN $args") + onOpenBuffer(BufferKey(serverId, args)) + } + "MSG" -> { + val msgSpaceIdx = args.indexOf(' ') + if (msgSpaceIdx >= 0) { + val target = args.substring(0, msgSpaceIdx) + val message = args.substring(msgSpaceIdx + 1) + ConnectionManager.sendChannelMessage(serverId, target, message) + } + } + else -> ConnectionManager.sendRaw(serverId, "$command $args".trim()) + } + } + } else { + ConnectionManager.sendChannelMessage(serverId, channelName, text) + } + }, + onKick = { nick -> ConnectionManager.connectionFor(serverId)?.kick(channelName, nick) }, + onBan = { nick -> ConnectionManager.connectionFor(serverId)?.ban(channelName, "$nick!*@*") }, + onOp = { nick -> ConnectionManager.connectionFor(serverId)?.setMode(channelName, "+o", nick) }, + onDeop = { nick -> ConnectionManager.connectionFor(serverId)?.setMode(channelName, "-o", nick) }, + onAlphaChange = { alpha -> + scope.launch(Dispatchers.IO) { prefsRepo.setAlpha(bufferKey.storageId(), alpha) } + }, + onTextColorChange = { index -> + scope.launch(Dispatchers.IO) { prefsRepo.setTextColorMircIndex(bufferKey.storageId(), index) } + }, + onFontSizeChange = { size -> + scope.launch(Dispatchers.IO) { prefsRepo.setFontSize(bufferKey.storageId(), size) } + }, + onShowUserListChange = { show -> + scope.launch(Dispatchers.IO) { prefsRepo.setShowUserList(bufferKey.storageId(), show) } + }, + onNickColorsToggle = { enabled -> + scope.launch(Dispatchers.IO) { prefsRepo.setNickColorsEnabled(bufferKey.storageId(), enabled) } + }, + onMentionHighlightColorChange = { colorArgb -> + scope.launch(Dispatchers.IO) { prefsRepo.setMentionHighlightColor(bufferKey.storageId(), colorArgb) } + }, + onShowImagePreviewsChange = { show -> + scope.launch(Dispatchers.IO) { prefsRepo.setShowImagePreviews(bufferKey.storageId(), show) } + } + ) + } + } +} diff --git a/src/main/kotlin/ui/MainScreen.kt b/src/main/kotlin/ui/MainScreen.kt new file mode 100644 index 0000000..e1670cd --- /dev/null +++ b/src/main/kotlin/ui/MainScreen.kt @@ -0,0 +1,359 @@ +package ui + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isShiftPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.text.input.ImeAction +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.ServerConfig +import data.ServerRepository +import irc.ConnectionManager +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Composable +fun MainScreen( + repo: ServerRepository, + colorScheme: ColorScheme, + onOpenChannel: (serverId: String, channelName: String) -> Unit, + onExitApp: () -> Unit +) { + var servers by remember { mutableStateOf(repo.loadAll()) } + var showAddDialog by remember { mutableStateOf(false) } + var editingServer by remember { mutableStateOf(null) } + var connectedIds by remember { mutableStateOf(setOf()) } + var channelInputs by remember { mutableStateOf(mapOf()) } + var openedChannels by remember { mutableStateOf(mapOf>()) } + val coroutineScope = rememberCoroutineScope() + + Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("wIRC", style = MaterialTheme.typography.headlineSmall) + Row { + Button(onClick = { editingServer = null; showAddDialog = true }) { + Text("+ Add Server") + } + Spacer(Modifier.width(8.dp)) + // Same end result as typing "/quit" alone in a channel window - disconnects + // every server and terminates the process - just reachable without needing + // a channel window open first. + TextButton(onClick = onExitApp) { + Text("Exit wIRC") + } + } + } + Spacer(Modifier.height(16.dp)) + + if (servers.isEmpty()) { + Text("No saved servers yet. Click \"+ Add Server\" to set one up.") + } + + LazyColumn(modifier = Modifier.weight(1f)) { + items(servers) { server -> + ServerCard( + server = server, + isConnected = server.id in connectedIds, + channelInput = channelInputs[server.id] ?: "#", + openedChannels = openedChannels[server.id] ?: emptyList(), + onChannelInputChange = { text -> channelInputs = channelInputs + (server.id to text) }, + onConnect = { + ConnectionManager.connect(server) + connectedIds = connectedIds + server.id + if (server.autoJoinChannels.isNotEmpty()) { + // Auto-join sends JOIN correctly on connect (see IrcConnection), + // but nothing else opens a window for it like the manual + // "Join + Open" flow does - so give the server a moment to + // actually join us before opening windows for them. + coroutineScope.launch { + delay(500) + server.autoJoinChannels.forEach { ch -> + val current = openedChannels[server.id] ?: emptyList() + if (ch !in current) openedChannels = openedChannels + (server.id to (current + ch)) + onOpenChannel(server.id, ch) + } + } + } + }, + onEdit = { editingServer = server; showAddDialog = true }, + onDelete = { + repo.remove(server.id) + servers = repo.loadAll() + }, + onJoinAndOpen = { ch -> + if (ch.isNotBlank()) { + ConnectionManager.connectionFor(server.id)?.joinChannel(ch) + val current = openedChannels[server.id] ?: emptyList() + if (ch !in current) openedChannels = openedChannels + (server.id to (current + ch)) + onOpenChannel(server.id, ch) + } + }, + onReopenWindow = { ch -> + // Re-send JOIN even though joinedChannels *should* already have + // rejoined this channel on any reconnect while the window was + // closed. JOIN is a no-op on the server if we're already in the + // channel, so this costs nothing - but it means "reopen" can never + // produce a window that looks open while the connection silently + // isn't actually in the channel (e.g. if a reconnect's rejoin was + // dropped, delayed, or raced - see IrcConnection's outbox/ircDebug + // logging). Same guarantee onJoinAndOpen already had. + ConnectionManager.connectionFor(server.id)?.joinChannel(ch) + onOpenChannel(server.id, ch) + } + ) + Spacer(Modifier.height(12.dp)) + } + } + } + + if (showAddDialog) { + ServerEditDialog( + existing = editingServer, + colorScheme = colorScheme, + onDismiss = { showAddDialog = false }, + onSave = { config -> + repo.upsert(config) + servers = repo.loadAll() + showAddDialog = false + } + ) + } +} + +@Composable +private fun ServerCard( + server: ServerConfig, + isConnected: Boolean, + channelInput: String, + openedChannels: List, + onChannelInputChange: (String) -> Unit, + onConnect: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit, + onJoinAndOpen: (String) -> Unit, + onReopenWindow: (String) -> Unit +) { + OutlinedCard(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text(server.label, style = MaterialTheme.typography.titleMedium) + Text( + "${server.host}:${server.port}${if (server.useTls) " (TLS)" else ""} · nick: ${server.nick}", + style = MaterialTheme.typography.bodySmall + ) + if (server.onConnectCommands.isNotEmpty()) { + Text( + "${server.onConnectCommands.size} on-connect command(s) saved", + style = MaterialTheme.typography.bodySmall + ) + } + } + Row { + TextButton(onClick = onEdit) { Text("Edit") } + TextButton(onClick = onDelete) { Text("Delete") } + } + } + + Spacer(Modifier.height(8.dp)) + + if (!isConnected) { + Button(onClick = onConnect) { Text("Connect") } + } else { + Text("Connected", style = MaterialTheme.typography.bodySmall) + Spacer(Modifier.height(4.dp)) + Row { + OutlinedTextField( + value = channelInput, + onValueChange = onChannelInputChange, + label = { Text("Channel") }, + modifier = Modifier.weight(1f) + ) + Spacer(Modifier.width(8.dp)) + Button(onClick = { onJoinAndOpen(channelInput.trim()) }) { + Text("Join + Open") + } + } + if (openedChannels.isNotEmpty()) { + Spacer(Modifier.height(4.dp)) + Text("Windows:", style = MaterialTheme.typography.labelSmall) + openedChannels.forEach { ch -> + TextButton(onClick = { onReopenWindow(ch) }) { Text(ch) } + } + } + } + } + } +} + +@Composable +private fun ServerEditDialog( + existing: ServerConfig?, + colorScheme: ColorScheme, + onDismiss: () -> Unit, + onSave: (ServerConfig) -> Unit +) { + var label by remember { mutableStateOf(existing?.label ?: "") } + var host by remember { mutableStateOf(existing?.host ?: "") } + var port by remember { mutableStateOf((existing?.port ?: 6667).toString()) } + var useTls by remember { mutableStateOf(existing?.useTls ?: false) } + var nick by remember { mutableStateOf(existing?.nick ?: "") } + var autoJoin by remember { mutableStateOf(existing?.autoJoinChannels?.joinToString(", ") ?: "") } + // One raw IRC command per line, e.g.: + // PRIVMSG NickServ :IDENTIFY hunter2 + var onConnectCommands by remember { + mutableStateOf(existing?.onConnectCommands?.joinToString("\n") ?: "") + } + val focusManager = LocalFocusManager.current + + fun save() { + val id = existing?.id ?: host.replace(Regex("[^a-zA-Z0-9]"), "_") + onSave( + ServerConfig( + id = id, + label = label.ifBlank { host }, + host = host, + port = port.toIntOrNull() ?: 6667, + useTls = useTls, + nick = nick.ifBlank { "guest${(1000..9999).random()}" }, + autoJoinChannels = autoJoin.split(",").map { it.trim() }.filter { it.isNotBlank() }, + // This dialog has no SASL fields of its own (SASL is currently only + // settable by hand-editing servers.json), so without carrying these + // over explicitly, saving any OTHER field here - like fixing an + // identify command - would silently null out an existing server's + // saslUser/saslPass on every edit. + saslUser = existing?.saslUser, + saslPass = existing?.saslPass, + onConnectCommands = onConnectCommands.split("\n").map { it.trim() }.filter { it.isNotBlank() } + ) + ) + } + + // A real, separate top-level window (DialogWindow), not a material3 AlertDialog. An + // AlertDialog on this Compose Multiplatform Desktop target renders as an overlay layer + // within the calling window's own canvas (a ComposeSceneLayer, not a native window of + // its own), so its outer bounds are hard-clipped to whatever size the main wIRC window + // currently has - the previous fix here (heightIn(max=420.dp) + an internal scroll on + // just the fields Column) only ever addressed the fields overflowing THEIR OWN space; it + // did nothing for a main window smaller than that, which could still clip the dialog's + // outer chrome outright. A DialogWindow is a genuine native OS window, sized and + // positioned on its own terms, so the main window's current size can no longer clip it - + // the same underlying fix as the channel-window settings panel (see ChannelScreen). + DialogWindow( + onCloseRequest = onDismiss, + title = if (existing == null) "Add Server" else "Edit Server", + state = rememberDialogState(size = DpSize(460.dp, 720.dp)), + resizable = true + ) { + MaterialTheme(colorScheme = colorScheme) { + Surface { + Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyDown && event.key == Key.Tab) { + focusManager.moveFocus(if (event.isShiftPressed) FocusDirection.Up else FocusDirection.Down) + true + } else false + } + ) { + OutlinedTextField( + value = label, + onValueChange = { label = it }, + label = { Text("Display name") }, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next) + ) + Spacer(Modifier.height(6.dp)) + OutlinedTextField( + value = host, + onValueChange = { host = it }, + label = { Text("Host") }, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next) + ) + Spacer(Modifier.height(6.dp)) + OutlinedTextField( + value = port, + onValueChange = { port = it }, + label = { Text("Port") }, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next) + ) + Spacer(Modifier.height(6.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = useTls, onCheckedChange = { useTls = it }) + Text("Use TLS") + } + OutlinedTextField( + value = nick, + onValueChange = { nick = it }, + label = { Text("Nick") }, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next) + ) + Spacer(Modifier.height(6.dp)) + OutlinedTextField( + value = autoJoin, + onValueChange = { autoJoin = it }, + label = { Text("Auto-join channels (comma separated)") }, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next) + ) + Spacer(Modifier.height(6.dp)) + OutlinedTextField( + value = onConnectCommands, + onValueChange = { onConnectCommands = it }, + label = { Text("On-connect / identify commands, one per line") }, + placeholder = { Text("PRIVMSG NickServ :IDENTIFY yourpassword") }, + modifier = Modifier.fillMaxWidth(), + minLines = 3, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) + ) + Text( + "Saved and sent automatically every time you connect, once the server " + + "finishes sending its MOTD and before auto-join. See the code comment " + + "on ServerRepository for a note on how this is (and isn't) protected at rest.", + style = MaterialTheme.typography.labelSmall + ) + } + Spacer(Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = onDismiss) { Text("Cancel") } + TextButton(onClick = { save() }, enabled = host.isNotBlank()) { Text("Save") } + } + } + } + } + } +} diff --git a/src/main/resources/icon.png b/src/main/resources/icon.png new file mode 100644 index 0000000..55aec2e Binary files /dev/null and b/src/main/resources/icon.png differ diff --git a/wIRC-0.1.0.exe b/wIRC-0.1.0.exe new file mode 100644 index 0000000..fcb6cff Binary files /dev/null and b/wIRC-0.1.0.exe differ diff --git a/wIRC-0.1.0.msi b/wIRC-0.1.0.msi new file mode 100644 index 0000000..0f1bcf0 Binary files /dev/null and b/wIRC-0.1.0.msi differ diff --git a/wIRC-0.1.1.exe b/wIRC-0.1.1.exe new file mode 100644 index 0000000..8e32370 Binary files /dev/null and b/wIRC-0.1.1.exe differ diff --git a/wIRC-0.1.1.msi b/wIRC-0.1.1.msi new file mode 100644 index 0000000..0dd0c87 Binary files /dev/null and b/wIRC-0.1.1.msi differ diff --git a/wIRC-0.1.2.exe b/wIRC-0.1.2.exe new file mode 100644 index 0000000..789db47 Binary files /dev/null and b/wIRC-0.1.2.exe differ diff --git a/wIRC-0.1.2.msi b/wIRC-0.1.2.msi new file mode 100644 index 0000000..a9b7987 Binary files /dev/null and b/wIRC-0.1.2.msi differ diff --git a/wIRC-0.1.3.exe b/wIRC-0.1.3.exe new file mode 100644 index 0000000..6d8c6da Binary files /dev/null and b/wIRC-0.1.3.exe differ diff --git a/wIRC-0.1.3.msi b/wIRC-0.1.3.msi new file mode 100644 index 0000000..0bad01a Binary files /dev/null and b/wIRC-0.1.3.msi differ diff --git a/wIRC-0.1.4.exe b/wIRC-0.1.4.exe new file mode 100644 index 0000000..8ba11ea Binary files /dev/null and b/wIRC-0.1.4.exe differ diff --git a/wIRC-0.1.4.msi b/wIRC-0.1.4.msi new file mode 100644 index 0000000..a33d186 Binary files /dev/null and b/wIRC-0.1.4.msi differ