Initial commit
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tpuahsiew.ircwindows"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.tpuahsiew.ircwindows"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "0.1"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.14"
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.core:core-ktx:1.13.1")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.4")
|
||||
implementation("androidx.activity:activity-compose:1.9.1")
|
||||
implementation(platform("androidx.compose:compose-bom:2024.09.00"))
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-graphics")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.datastore:datastore-preferences:1.1.1")
|
||||
implementation("androidx.lifecycle:lifecycle-service:2.8.4")
|
||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||
implementation("io.coil-kt:coil-compose:2.6.0")
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:name=".IrcWindowsApp"
|
||||
android:allowBackup="false"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.IrcWindows.Transparent"
|
||||
android:supportsRtl="true">
|
||||
|
||||
<!-- Main window: server / channel list, add-connection screen -->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:resizeableActivity="true"
|
||||
android:theme="@style/Theme.IrcWindows.Transparent">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- One of these gets launched per channel. documentLaunchMode="always" +
|
||||
resizeableActivity lets Android treat each launch as its own task, which is
|
||||
what allows multiple independent, draggable/resizable windows on devices
|
||||
that support freeform / multi-window (large tablets, DeX, etc). -->
|
||||
<activity
|
||||
android:name=".ChannelWindowActivity"
|
||||
android:exported="false"
|
||||
android:resizeableActivity="true"
|
||||
android:documentLaunchMode="always"
|
||||
android:launchMode="standard"
|
||||
android:theme="@style/Theme.IrcWindows.Transparent"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation" />
|
||||
|
||||
<service
|
||||
android:name=".irc.IrcConnectionService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,141 @@
|
||||
package com.tpuahsiew.ircwindows
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.tpuahsiew.ircwindows.data.BufferKey
|
||||
import com.tpuahsiew.ircwindows.data.WindowDisplaySettings
|
||||
import com.tpuahsiew.ircwindows.data.WindowPrefsRepository
|
||||
import com.tpuahsiew.ircwindows.data.isChannelName
|
||||
import com.tpuahsiew.ircwindows.data.storageId
|
||||
import com.tpuahsiew.ircwindows.irc.ConnectionManager
|
||||
import com.tpuahsiew.ircwindows.irc.IrcConnectionService
|
||||
import com.tpuahsiew.ircwindows.ui.ChannelScreen
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Each channel a user opens gets its own instance of this Activity (see manifest:
|
||||
* documentLaunchMode="always", resizeableActivity="true"). On devices with freeform /
|
||||
* multi-window support this lets multiple channels sit on screen as separate,
|
||||
* independently draggable/resizable windows rather than tabs inside one window.
|
||||
*/
|
||||
class ChannelWindowActivity : ComponentActivity() {
|
||||
|
||||
companion object {
|
||||
const val EXTRA_SERVER_ID = "server_id"
|
||||
const val EXTRA_CHANNEL_NAME = "channel_name"
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val serverId = intent.getStringExtra(EXTRA_SERVER_ID) ?: run { finish(); return }
|
||||
val channelName = intent.getStringExtra(EXTRA_CHANNEL_NAME) ?: run { finish(); return }
|
||||
val bufferKey = BufferKey(serverId, channelName)
|
||||
val windowTitle = if (isChannelName(channelName)) channelName else "PM: $channelName"
|
||||
setTaskDescription(ActivityManager.TaskDescription(windowTitle))
|
||||
|
||||
val prefsRepo = WindowPrefsRepository(applicationContext)
|
||||
val displaySettingsFlow: StateFlow<WindowDisplaySettings> =
|
||||
MutableStateFlow(WindowDisplaySettings())
|
||||
lifecycleScope.launch {
|
||||
prefsRepo.settingsFor(bufferKey.storageId()).collect { settings ->
|
||||
(displaySettingsFlow as MutableStateFlow).value = settings
|
||||
}
|
||||
}
|
||||
|
||||
setContent {
|
||||
val buffers by ConnectionManager.buffers.collectAsState()
|
||||
val displaySettings by displaySettingsFlow.collectAsState()
|
||||
val ownNicks by ConnectionManager.ownNicksFlow.collectAsState()
|
||||
val buffer = buffers[bufferKey]
|
||||
|
||||
ChannelScreen(
|
||||
channelName = windowTitle,
|
||||
buffer = buffer,
|
||||
displaySettings = displaySettings,
|
||||
ownNick = ownNicks[serverId],
|
||||
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()
|
||||
|
||||
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) finish()
|
||||
}
|
||||
"QUIT" -> {
|
||||
if (rawArgs.isBlank()) {
|
||||
ConnectionManager.disconnectAll()
|
||||
stopService(Intent(this, IrcConnectionService::class.java))
|
||||
kotlin.system.exitProcess(0)
|
||||
} else {
|
||||
ConnectionManager.sendChannelMessage(serverId, channelName, rawArgs)
|
||||
}
|
||||
}
|
||||
"JOIN" -> {
|
||||
ConnectionManager.sendRaw(serverId, "JOIN $args")
|
||||
val joinIntent = Intent(this, ChannelWindowActivity::class.java).apply {
|
||||
putExtra(EXTRA_SERVER_ID, serverId)
|
||||
putExtra(EXTRA_CHANNEL_NAME, args)
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_DOCUMENT or Intent.FLAG_ACTIVITY_MULTIPLE_TASK
|
||||
}
|
||||
startActivity(joinIntent)
|
||||
}
|
||||
"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 ->
|
||||
lifecycleScope.launch { prefsRepo.setAlpha(bufferKey.storageId(), alpha) }
|
||||
},
|
||||
onTextColorChange = { index ->
|
||||
lifecycleScope.launch { prefsRepo.setTextColorMircIndex(bufferKey.storageId(), index) }
|
||||
},
|
||||
onFontSizeChange = { size ->
|
||||
lifecycleScope.launch { prefsRepo.setFontSize(bufferKey.storageId(), size) }
|
||||
},
|
||||
onShowUserListChange = { show ->
|
||||
lifecycleScope.launch { prefsRepo.setShowUserList(bufferKey.storageId(), show) }
|
||||
},
|
||||
onNickColorsToggle = { enabled ->
|
||||
lifecycleScope.launch { prefsRepo.setNickColorsEnabled(bufferKey.storageId(), enabled) }
|
||||
},
|
||||
onMentionHighlightColorChange = { colorArgb ->
|
||||
lifecycleScope.launch { prefsRepo.setMentionHighlightColor(bufferKey.storageId(), colorArgb) }
|
||||
},
|
||||
onShowImagePreviewsChange = { show ->
|
||||
lifecycleScope.launch { prefsRepo.setShowImagePreviews(bufferKey.storageId(), show) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.tpuahsiew.ircwindows
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import com.tpuahsiew.ircwindows.irc.ConnectionManager
|
||||
import com.tpuahsiew.ircwindows.irc.IrcConnectionService
|
||||
|
||||
class IrcWindowsApp : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
ConnectionManager.init(this)
|
||||
val intent = Intent(this, IrcConnectionService::class.java)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
startForegroundService(intent)
|
||||
} else {
|
||||
startService(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
package com.tpuahsiew.ircwindows
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
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.graphics.Color
|
||||
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.dp
|
||||
import com.tpuahsiew.ircwindows.data.ServerConfig
|
||||
import com.tpuahsiew.ircwindows.data.ServerRepository
|
||||
import com.tpuahsiew.ircwindows.irc.ConnectionManager
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// 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.
|
||||
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
|
||||
)
|
||||
|
||||
/**
|
||||
* Main window. Resizable like every other window in the app (see manifest), so on
|
||||
* devices with freeform multi-window support it gets the OS's own window chrome -
|
||||
* including minimize - for free; there's no app-level "minimize" API to build.
|
||||
*/
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setTaskDescription(ActivityManager.TaskDescription("wIRC - Main"))
|
||||
val repo = ServerRepository(applicationContext)
|
||||
setContent {
|
||||
MaterialTheme(colorScheme = neutralDarkScheme) {
|
||||
Surface {
|
||||
MainScreen(
|
||||
repo = repo,
|
||||
onOpenChannel = { serverId, channelName ->
|
||||
val intent = Intent(this, ChannelWindowActivity::class.java).apply {
|
||||
putExtra(ChannelWindowActivity.EXTRA_SERVER_ID, serverId)
|
||||
putExtra(ChannelWindowActivity.EXTRA_CHANNEL_NAME, channelName)
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_DOCUMENT or Intent.FLAG_ACTIVITY_MULTIPLE_TASK
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MainScreen(repo: ServerRepository, onOpenChannel: (serverId: String, channelName: String) -> Unit) {
|
||||
var servers by remember { mutableStateOf(repo.loadAll()) }
|
||||
var showAddDialog by remember { mutableStateOf(false) }
|
||||
var editingServer by remember { mutableStateOf<ServerConfig?>(null) }
|
||||
var connectedIds by remember { mutableStateOf(setOf<String>()) }
|
||||
var channelInputs by remember { mutableStateOf(mapOf<String, String>()) }
|
||||
var openedChannels by remember { mutableStateOf(mapOf<String, List<String>>()) }
|
||||
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)
|
||||
Button(onClick = { editingServer = null; showAddDialog = true }) {
|
||||
Text("+ Add Server")
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
if (servers.isEmpty()) {
|
||||
Text("No saved servers yet. Tap \"+ 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 -> onOpenChannel(server.id, ch) }
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showAddDialog) {
|
||||
ServerEditDialog(
|
||||
existing = editingServer,
|
||||
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<String>,
|
||||
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 ""} \u00b7 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?,
|
||||
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
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(if (existing == null) "Add Server" else "Edit Server") },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.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 (encrypted) and sent automatically every time you connect, " +
|
||||
"right after registering and before auto-join.",
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
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() },
|
||||
onConnectCommands = onConnectCommands.split("\n").map { it.trim() }.filter { it.isNotBlank() }
|
||||
)
|
||||
)
|
||||
}, enabled = host.isNotBlank()) { Text("Save") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.tpuahsiew.ircwindows.data
|
||||
|
||||
import android.content.Context
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Persists each buffer's scrollback to a JSON file under filesDir/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 context: Context) {
|
||||
|
||||
private fun fileFor(key: BufferKey): File {
|
||||
val dir = File(context.filesDir, "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<IrcMessage> {
|
||||
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<IrcMessage>) {
|
||||
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" }
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.tpuahsiew.ircwindows.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<String> = 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<String> = 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 }
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
/** 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<IrcMessage> = emptyList(),
|
||||
val users: List<ChannelUser> = emptyList(),
|
||||
val topic: String = ""
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.tpuahsiew.ircwindows.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Saves the server list — including each server's "on join perform" commands
|
||||
* (NickServ/ChanServ identify, etc.) — as a single JSON blob inside an
|
||||
* EncryptedSharedPreferences store backed by an Android Keystore master key,
|
||||
* so identify passwords never sit in plaintext on disk. Loaded once at app
|
||||
* start and replayed automatically on every connect, so you only ever type
|
||||
* your identify commands in once.
|
||||
*/
|
||||
class ServerRepository(context: Context) {
|
||||
|
||||
private val prefs: SharedPreferences = run {
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"servers_encrypted",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
}
|
||||
|
||||
fun loadAll(): List<ServerConfig> {
|
||||
val json = prefs.getString(SERVERS_KEY, null) ?: return emptyList()
|
||||
return try {
|
||||
val arr = JSONArray(json)
|
||||
(0 until arr.length()).map { i -> fromJson(arr.getJSONObject(i)) }
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
fun saveAll(servers: List<ServerConfig>) {
|
||||
val arr = JSONArray()
|
||||
servers.forEach { arr.put(toJson(it)) }
|
||||
prefs.edit().putString(SERVERS_KEY, arr.toString()).apply()
|
||||
}
|
||||
|
||||
fun upsert(server: ServerConfig) {
|
||||
val current = loadAll().toMutableList()
|
||||
val idx = current.indexOfFirst { it.id == server.id }
|
||||
if (idx >= 0) current[idx] = server else current.add(server)
|
||||
saveAll(current)
|
||||
}
|
||||
|
||||
fun remove(serverId: String) {
|
||||
saveAll(loadAll().filterNot { it.id == serverId })
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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")
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val SERVERS_KEY = "servers_json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.tpuahsiew.ircwindows.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.*
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
val Context.windowPrefsStore by preferencesDataStore(name = "window_prefs")
|
||||
|
||||
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, // translucent orange
|
||||
val nickColorsEnabled: Boolean = true,
|
||||
val showImagePreviews: Boolean = false // opt-in: auto-loading images reveals your IP to the host
|
||||
)
|
||||
|
||||
/**
|
||||
* Settings are keyed per-buffer (server+channel) so each floating window can be
|
||||
* configured independently, matching how the windows themselves are independent.
|
||||
*/
|
||||
class WindowPrefsRepository(private val context: Context) {
|
||||
|
||||
private fun alphaKey(bufferKeyId: String) = floatPreferencesKey("alpha_$bufferKeyId")
|
||||
private fun textColorMircIndexKey(bufferKeyId: String) = intPreferencesKey("textcolormircindex_$bufferKeyId")
|
||||
private fun fontSizeKey(bufferKeyId: String) = floatPreferencesKey("fontsize_$bufferKeyId")
|
||||
private fun showUserListKey(bufferKeyId: String) = booleanPreferencesKey("showuserlist_$bufferKeyId")
|
||||
private fun mentionColorKey(bufferKeyId: String) = intPreferencesKey("mentioncolor_$bufferKeyId")
|
||||
private fun nickColorsEnabledKey(bufferKeyId: String) = booleanPreferencesKey("nickcolors_$bufferKeyId")
|
||||
private fun showImagePreviewsKey(bufferKeyId: String) = booleanPreferencesKey("showimagepreviews_$bufferKeyId")
|
||||
|
||||
fun settingsFor(bufferKeyId: String): Flow<WindowDisplaySettings> {
|
||||
return context.windowPrefsStore.data.map { prefs ->
|
||||
WindowDisplaySettings(
|
||||
backgroundAlpha = prefs[alphaKey(bufferKeyId)] ?: 0.85f,
|
||||
textColorMircIndex = prefs[textColorMircIndexKey(bufferKeyId)] ?: 0,
|
||||
fontSize = prefs[fontSizeKey(bufferKeyId)] ?: 14f,
|
||||
showUserList = prefs[showUserListKey(bufferKeyId)] ?: true,
|
||||
mentionHighlightColorArgb = prefs[mentionColorKey(bufferKeyId)] ?: 0x40FFA500,
|
||||
nickColorsEnabled = prefs[nickColorsEnabledKey(bufferKeyId)] ?: true,
|
||||
showImagePreviews = prefs[showImagePreviewsKey(bufferKeyId)] ?: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setAlpha(bufferKeyId: String, alpha: Float) {
|
||||
context.windowPrefsStore.edit { it[alphaKey(bufferKeyId)] = alpha }
|
||||
}
|
||||
|
||||
suspend fun setTextColorMircIndex(bufferKeyId: String, index: Int) {
|
||||
context.windowPrefsStore.edit { it[textColorMircIndexKey(bufferKeyId)] = index }
|
||||
}
|
||||
|
||||
suspend fun setFontSize(bufferKeyId: String, fontSize: Float) {
|
||||
context.windowPrefsStore.edit { it[fontSizeKey(bufferKeyId)] = fontSize }
|
||||
}
|
||||
|
||||
suspend fun setShowUserList(bufferKeyId: String, show: Boolean) {
|
||||
context.windowPrefsStore.edit { it[showUserListKey(bufferKeyId)] = show }
|
||||
}
|
||||
|
||||
suspend fun setMentionHighlightColor(bufferKeyId: String, colorArgb: Int) {
|
||||
context.windowPrefsStore.edit { it[mentionColorKey(bufferKeyId)] = colorArgb }
|
||||
}
|
||||
|
||||
suspend fun setNickColorsEnabled(bufferKeyId: String, enabled: Boolean) {
|
||||
context.windowPrefsStore.edit { it[nickColorsEnabledKey(bufferKeyId)] = enabled }
|
||||
}
|
||||
|
||||
suspend fun setShowImagePreviews(bufferKeyId: String, show: Boolean) {
|
||||
context.windowPrefsStore.edit { it[showImagePreviewsKey(bufferKeyId)] = show }
|
||||
}
|
||||
}
|
||||
|
||||
fun BufferKey.storageId(): String = "${serverId}__${name}"
|
||||
@@ -0,0 +1,250 @@
|
||||
package com.tpuahsiew.ircwindows.irc
|
||||
|
||||
import android.content.Context
|
||||
import com.tpuahsiew.ircwindows.data.*
|
||||
import com.tpuahsiew.ircwindows.data.isChannelName
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
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 java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* App-wide singleton. Owns every IrcConnection and every ChannelBuffer, so that all
|
||||
* windows (Activities) read from the same source of truth via StateFlow.
|
||||
*/
|
||||
object ConnectionManager {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val connections = ConcurrentHashMap<String, IrcConnection>()
|
||||
private val ownNicks = ConcurrentHashMap<String, String>() // serverId -> our current nick
|
||||
private var historyRepo: MessageHistoryRepository? = null
|
||||
|
||||
private val _buffers = MutableStateFlow<Map<BufferKey, ChannelBuffer>>(emptyMap())
|
||||
val buffers: StateFlow<Map<BufferKey, ChannelBuffer>> = _buffers
|
||||
|
||||
private val _statuses = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||
val statuses: StateFlow<Map<String, String>> = _statuses
|
||||
|
||||
private val _ownNicks = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||
val ownNicksFlow: StateFlow<Map<String, String>> = _ownNicks
|
||||
|
||||
fun init(context: Context) {
|
||||
historyRepo = MessageHistoryRepository(context.applicationContext)
|
||||
}
|
||||
|
||||
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). IrcConnectionService listens to this to
|
||||
// auto-launch a window for it, same as tapping a channel manually.
|
||||
private val _newQueryBuffers = MutableSharedFlow<BufferKey>(extraBufferCapacity = 16)
|
||||
val newQueryBuffers: SharedFlow<BufferKey> = _newQueryBuffers
|
||||
|
||||
fun connect(config: ServerConfig) {
|
||||
if (connections.containsKey(config.id)) return
|
||||
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()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
private fun addMessage(key: BufferKey, msg: IrcMessage) {
|
||||
val isNewBuffer = !_buffers.value.containsKey(key)
|
||||
// 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) {
|
||||
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(500)
|
||||
map + (key to existing.copy(messages = trimmed))
|
||||
}
|
||||
val toSave = _buffers.value[key]?.messages ?: emptyList()
|
||||
historyRepo?.saveMessages(key, toSave)
|
||||
}
|
||||
if (isNewBuffer && !isChannelName(key.name)) {
|
||||
scope.launch { _newQueryBuffers.emit(key) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateUsers(key: BufferKey, transform: (List<ChannelUser>) -> List<ChannelUser>) {
|
||||
_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("\u0001ACTION") && text.endsWith("\u0001")
|
||||
val body = if (isAction) text.removePrefix("\u0001ACTION ").removeSuffix("\u0001") 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
|
||||
addMessage(BufferKey(serverId, target), 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package com.tpuahsiew.ircwindows.irc
|
||||
|
||||
import com.tpuahsiew.ircwindows.data.ServerConfig
|
||||
import kotlinx.coroutines.*
|
||||
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
|
||||
|
||||
/**
|
||||
* 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
|
||||
) {
|
||||
private var socket: Socket? = null
|
||||
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<String>()
|
||||
|
||||
fun connect() {
|
||||
wantConnected = true
|
||||
readJob?.cancel()
|
||||
readJob = scope.launch(Dispatchers.IO) {
|
||||
while (wantConnected && isActive) {
|
||||
try {
|
||||
onStatus("Connecting to ${config.host}:${config.port}...")
|
||||
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
|
||||
|
||||
if (!config.saslUser.isNullOrBlank()) {
|
||||
out.println("CAP REQ :sasl")
|
||||
}
|
||||
out.println("NICK ${config.nick}")
|
||||
out.println("USER ${config.nick} 0 * :${config.nick}")
|
||||
|
||||
backoffMillis = 2000L
|
||||
onStatus("Connected, registering...")
|
||||
|
||||
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}")
|
||||
} finally {
|
||||
closeSocketQuietly()
|
||||
}
|
||||
|
||||
if (wantConnected) {
|
||||
onStatus("Reconnecting in ${backoffMillis / 1000}s...")
|
||||
delay(backoffMillis)
|
||||
backoffMillis = (backoffMillis * 2).coerceAtMost(60_000L)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleRawLine(line: String) {
|
||||
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("\u0001") && text.endsWith("\u0001") && text.length > 1) {
|
||||
val ctcpBody = text.removePrefix("\u0001").removeSuffix("\u0001")
|
||||
val ctcpCommand = ctcpBody.substringBefore(' ').uppercase()
|
||||
if (ctcpCommand == "VERSION") {
|
||||
val replyTo = nickFromPrefix(parsed.prefix)
|
||||
if (replyTo != null) {
|
||||
send("NOTICE $replyTo :\u0001VERSION It's an irc client, fucko!\u0001")
|
||||
}
|
||||
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") {
|
||||
// Registered. Run "on join perform" commands first (NickServ/ChanServ
|
||||
// identify, etc.) so we're identified before joining anything that
|
||||
// might require it, then rejoin every channel we were actually in -
|
||||
// the originally configured auto-join list plus anything joined
|
||||
// manually mid-session, so a reconnect doesn't forget those.
|
||||
config.onConnectCommands.forEach { cmd -> if (cmd.isNotBlank()) send(cmd) }
|
||||
(config.autoJoinChannels + joinedChannels).distinct().forEach { ch -> joinChannel(ch) }
|
||||
}
|
||||
onLine(parsed)
|
||||
}
|
||||
|
||||
fun send(raw: String) {
|
||||
scope.launch(Dispatchers.IO) { writer?.println(raw) }
|
||||
}
|
||||
|
||||
fun sendMessage(target: String, text: String) {
|
||||
send("PRIVMSG $target :$text")
|
||||
}
|
||||
|
||||
fun joinChannel(channel: String) {
|
||||
joinedChannels.add(channel)
|
||||
send("JOIN $channel")
|
||||
}
|
||||
|
||||
fun partChannel(channel: String, reason: String = "") {
|
||||
joinedChannels.remove(channel)
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.tpuahsiew.ircwindows.irc
|
||||
|
||||
import android.app.*
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.lifecycle.LifecycleService
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.tpuahsiew.ircwindows.ChannelWindowActivity
|
||||
import com.tpuahsiew.ircwindows.MainActivity
|
||||
import com.tpuahsiew.ircwindows.R
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Foreground service whose only job is to keep the process (and therefore every
|
||||
* IrcConnection socket held by ConnectionManager) alive while the app is backgrounded.
|
||||
* Android aggressively kills backgrounded network connections without this.
|
||||
*/
|
||||
class IrcConnectionService : LifecycleService() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createNotificationChannel()
|
||||
startForeground(NOTIFICATION_ID, buildNotification())
|
||||
|
||||
// Auto-open a window for every brand-new private message, the same way
|
||||
// tapping a channel does. Uses NEW_TASK since we're launching from a
|
||||
// Service context, not an Activity.
|
||||
lifecycleScope.launch {
|
||||
ConnectionManager.newQueryBuffers.collect { key ->
|
||||
val intent = Intent(this@IrcConnectionService, ChannelWindowActivity::class.java).apply {
|
||||
putExtra(ChannelWindowActivity.EXTRA_SERVER_ID, key.serverId)
|
||||
putExtra(ChannelWindowActivity.EXTRA_CHANNEL_NAME, key.name)
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or
|
||||
Intent.FLAG_ACTIVITY_NEW_DOCUMENT or
|
||||
Intent.FLAG_ACTIVITY_MULTIPLE_TASK
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
super.onStartCommand(intent, flags, startId)
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
private fun buildNotification(): Notification {
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this, 0,
|
||||
Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("wIRC")
|
||||
.setContentText("Connected")
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID, "IRC connection", NotificationManager.IMPORTANCE_LOW
|
||||
)
|
||||
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHANNEL_ID = "irc_connection"
|
||||
const val NOTIFICATION_ID = 1001
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.tpuahsiew.ircwindows.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<String> // 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
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package com.tpuahsiew.ircwindows.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 = '\u0002'
|
||||
private const val COLOR = '\u0003'
|
||||
private const val RESET = '\u000F'
|
||||
private const val REVERSE = '\u0016'
|
||||
private const val UNDERLINE = '\u001F'
|
||||
|
||||
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<String> {
|
||||
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<Int?, Int> {
|
||||
// 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()
|
||||
}
|
||||
@@ -0,0 +1,707 @@
|
||||
package com.tpuahsiew.ircwindows.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
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.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.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.SolidColor
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.input.key.*
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
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.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import com.tpuahsiew.ircwindows.data.ChannelBuffer
|
||||
import com.tpuahsiew.ircwindows.data.ChannelUser
|
||||
import com.tpuahsiew.ircwindows.data.MessageKind
|
||||
import com.tpuahsiew.ircwindows.data.WindowDisplaySettings
|
||||
import com.tpuahsiew.ircwindows.irc.extractImageUrls
|
||||
import com.tpuahsiew.ircwindows.irc.mircPalette
|
||||
import com.tpuahsiew.ircwindows.irc.parseMircToAnnotatedString
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import kotlin.math.floor
|
||||
|
||||
// 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<String>,
|
||||
val index: Int,
|
||||
val textAfter: String,
|
||||
val cursorAfter: Int
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ChannelScreen(
|
||||
channelName: String,
|
||||
buffer: ChannelBuffer?,
|
||||
displaySettings: WindowDisplaySettings,
|
||||
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<ChannelUser?>(null) }
|
||||
var showColorPicker by remember { mutableStateOf(false) }
|
||||
var tabState by remember { mutableStateOf<TabCompletionState?>(null) }
|
||||
val history = remember { mutableStateListOf<String>() }
|
||||
var historyIndex by remember { mutableStateOf(-1) }
|
||||
var draftBeforeHistory by remember { mutableStateOf("") }
|
||||
val inputFocusRequester = remember { FocusRequester() }
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
val context = LocalContext.current
|
||||
|
||||
// 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 = "\u0003" + 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("HH:mm", Locale.getDefault()) }
|
||||
|
||||
LaunchedEffect(buffer?.messages?.size) {
|
||||
if ((buffer?.messages?.size ?: 0) > 0) {
|
||||
listState.animateScrollToItem((buffer!!.messages.size - 1).coerceAtLeast(0))
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
inputFocusRequester.requestFocus()
|
||||
keyboardController?.show()
|
||||
}
|
||||
.background(bgColor)
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Title bar: channel name + settings gear. Deliberately plain, no theming.
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showSettings) {
|
||||
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()
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val textMeasurer = rememberTextMeasurer()
|
||||
val lineHeightPx = remember(displaySettings.fontSize) {
|
||||
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() }
|
||||
val snappedHeight = if (lineHeightDp > 0.dp) {
|
||||
lineHeightDp * floor(maxHeight / lineHeightDp).toInt()
|
||||
} else {
|
||||
maxHeight
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.height(snappedHeight)
|
||||
.padding(horizontal = 6.dp)
|
||||
) {
|
||||
items(buffer?.messages ?: emptyList()) { 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()) {
|
||||
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,
|
||||
fontSize = displaySettings.fontSize.sp
|
||||
)
|
||||
Text(
|
||||
text = "<${msg.sender}>",
|
||||
color = nickColor,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = displaySettings.fontSize.sp
|
||||
)
|
||||
Text(
|
||||
text = " ",
|
||||
color = mutedColor,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = displaySettings.fontSize.sp
|
||||
)
|
||||
}
|
||||
MessageKind.ACTION -> {
|
||||
Text(
|
||||
text = "[$ts] * ",
|
||||
color = mutedColor,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = displaySettings.fontSize.sp
|
||||
)
|
||||
Text(
|
||||
text = "${msg.sender} ",
|
||||
color = nickColor,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = displaySettings.fontSize.sp
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
Text(
|
||||
text = "[$ts] * ",
|
||||
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 ->
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(annotation.item)))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
if (displaySettings.showImagePreviews) {
|
||||
extractImageUrls(msg.rawText).forEach { url ->
|
||||
AsyncImage(
|
||||
model = url,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp, top = 2.dp, bottom = 4.dp)
|
||||
.heightIn(max = 200.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.clickable {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
|
||||
},
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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. Tablet-with-keyboard friendly.
|
||||
if (event.type == KeyEventType.KeyDown && event.isCtrlPressed) {
|
||||
when (event.key) {
|
||||
Key.K -> { showColorPicker = true; true }
|
||||
Key.B -> { insertControlChar('\u0002'); true }
|
||||
Key.I -> { insertControlChar('\u001D'); true }
|
||||
else -> false
|
||||
}
|
||||
} else if (event.type == KeyEventType.KeyDown && event.key == Key.Enter && !event.isCtrlPressed) {
|
||||
// Hardware Enter sends, same as the software keyboard's
|
||||
// ImeAction.Send below. Ctrl+Enter is left alone in case
|
||||
// it's wanted for something else later.
|
||||
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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Standard 16-color mIRC palette swatches, same indices the wire protocol uses
|
||||
// (\u0003FG,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<Int?>(null) }
|
||||
var selectedBg by remember { mutableStateOf<Int?>(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) \u2192")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { if (selectedFg != null) onPick(selectedFg!!, selectedBg) },
|
||||
enabled = selectedFg != null
|
||||
) { Text("Insert") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FlowSwatchGrid(selected: Int?, onSelect: (Int) -> Unit) {
|
||||
Column {
|
||||
mircSwatches.chunked(4).forEach { row ->
|
||||
Row {
|
||||
row.forEach { (idx, color) ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(2.dp)
|
||||
.size(36.dp)
|
||||
.background(color)
|
||||
.border(if (selected == idx) 3.dp else 1.dp, Color.Black)
|
||||
.clickableNoRipple { onSelect(idx) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun Modifier.clickableNoRipple(onClick: () -> Unit): Modifier = this.then(
|
||||
Modifier.combinedClickable(onClick = onClick, onLongClick = {})
|
||||
)
|
||||
|
||||
// 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.
|
||||
@Composable
|
||||
private fun MentionColorPickerDialog(onDismiss: () -> Unit, onPick: (Int) -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Mention highlight color") },
|
||||
text = {
|
||||
FlowSwatchGrid(
|
||||
selected = null,
|
||||
onSelect = { idx ->
|
||||
val rgb = mircSwatches.first { it.first == idx }.second.toArgb()
|
||||
val translucentArgb = (0x40 shl 24) or (rgb and 0x00FFFFFF)
|
||||
onPick(translucentArgb)
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
},
|
||||
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)
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.border(1.dp, Color.Black)
|
||||
.padding(8.dp)
|
||||
) {
|
||||
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(
|
||||
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") }
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:fillColor="#000000" android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M20,30 L88,30 L88,70 L45,70 L28,86 L28,70 L20,70 Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M4,4 L20,4 L20,16 L10,16 L6,20 L6,16 L4,16 Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,4 @@
|
||||
<resources>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">wIRC</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,17 @@
|
||||
<resources>
|
||||
<!-- Base app theme, plain Material3 dark, no bloggin.space branding -->
|
||||
<style name="Theme.IrcWindows" parent="android:Theme.Material.NoActionBar">
|
||||
</style>
|
||||
|
||||
<!-- Transparent variant used by every Activity so the Compose layer fully controls
|
||||
its own background alpha, border, and chrome instead of the system decorations. -->
|
||||
<style name="Theme.IrcWindows.Transparent" parent="Theme.IrcWindows">
|
||||
<item name="android:windowIsTranslucent">true</item>
|
||||
<item name="android:windowBackground">@android:color/transparent</item>
|
||||
<item name="android:windowNoTitle">true</item>
|
||||
<item name="android:windowContentOverlay">@null</item>
|
||||
<item name="android:windowAnimationStyle">@android:style/Animation</item>
|
||||
<item name="android:colorBackgroundCacheHint">@null</item>
|
||||
<item name="android:windowIsFloating">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user