From e7239980c6c4d5abc7a0ccd257ca1c176f06a6c7 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 23 Feb 2026 11:53:31 +0200 Subject: [PATCH 01/13] feat(desktop): add multi-column deck layout replacing NavigationRail Replace the 80dp NavigationRail + single content area with a TweetDeck-style multi-column deck. Default layout: Home Feed + Notifications + Messages. New deck system: - DeckColumnType sealed class with 12 column types - DeckState with StateFlow-based column management - Per-column navigation stack for in-column drill-down - Draggable dividers for column resize (300-800dp) - Column config persisted via Jackson JSON in DesktopPreferences - 48dp thin sidebar with add-column and settings buttons - AddColumnDialog for picking column types Keyboard shortcuts: - Cmd+T: Add column - Cmd+W: Close focused column - Cmd+1-9: Focus column by index - Cmd+Shift+Left/Right: Move column left/right Co-Authored-By: Claude Opus 4.6 --- .../amethyst/desktop/DesktopPreferences.kt | 7 + .../vitorpamplona/amethyst/desktop/Main.kt | 403 +++++++----------- .../desktop/ui/deck/AddColumnDialog.kt | 162 +++++++ .../amethyst/desktop/ui/deck/ColumnHeader.kt | 129 ++++++ .../desktop/ui/deck/DeckColumnContainer.kt | 365 ++++++++++++++++ .../desktop/ui/deck/DeckColumnType.kt | 93 ++++ .../amethyst/desktop/ui/deck/DeckLayout.kt | 122 ++++++ .../amethyst/desktop/ui/deck/DeckSidebar.kt | 89 ++++ .../amethyst/desktop/ui/deck/DeckState.kt | 163 +++++++ 9 files changed, 1279 insertions(+), 254 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt index a78c3b68e..2dbcbbdd3 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt @@ -35,6 +35,7 @@ object DesktopPreferences { private const val KEY_FEED_MODE = "feed_mode" private const val KEY_LAST_SCREEN = "last_screen" + private const val KEY_DECK_COLUMNS = "deck_columns" var feedMode: FeedMode get() { @@ -54,4 +55,10 @@ object DesktopPreferences { set(value) { prefs.put(KEY_LAST_SCREEN, value) } + + var deckColumns: String + get() = prefs.get(KEY_DECK_COLUMNS, "") + set(value) { + prefs.put(KEY_DECK_COLUMNS, value) + } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index e97b2f18a..86099ffdc 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -25,31 +25,20 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Article -import androidx.compose.material.icons.filled.Email -import androidx.compose.material.icons.filled.Home -import androidx.compose.material.icons.filled.Notifications -import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.NavigationRail -import androidx.compose.material3.NavigationRailItem import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SnackbarHost @@ -82,21 +71,16 @@ import com.vitorpamplona.amethyst.desktop.account.AccountManager import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState -import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator -import com.vitorpamplona.amethyst.desktop.ui.BookmarksScreen import com.vitorpamplona.amethyst.desktop.ui.ComposeNoteDialog -import com.vitorpamplona.amethyst.desktop.ui.FeedScreen import com.vitorpamplona.amethyst.desktop.ui.LoginScreen -import com.vitorpamplona.amethyst.desktop.ui.NotificationsScreen -import com.vitorpamplona.amethyst.desktop.ui.ReadsScreen -import com.vitorpamplona.amethyst.desktop.ui.SearchScreen -import com.vitorpamplona.amethyst.desktop.ui.ThreadScreen -import com.vitorpamplona.amethyst.desktop.ui.UserProfileScreen import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback -import com.vitorpamplona.amethyst.desktop.ui.chats.DesktopMessagesScreen -import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker +import com.vitorpamplona.amethyst.desktop.ui.deck.AddColumnDialog +import com.vitorpamplona.amethyst.desktop.ui.deck.DeckColumnType +import com.vitorpamplona.amethyst.desktop.ui.deck.DeckLayout +import com.vitorpamplona.amethyst.desktop.ui.deck.DeckSidebar +import com.vitorpamplona.amethyst.desktop.ui.deck.DeckState import com.vitorpamplona.amethyst.desktop.ui.profile.ProfileInfoCard import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect @@ -109,7 +93,7 @@ import kotlinx.coroutines.launch private val isMacOS = System.getProperty("os.name").lowercase().contains("mac") /** - * Desktop navigation state - extends AppScreen with dynamic destinations. + * Desktop navigation state — used for in-column navigation (drill-down). */ sealed class DesktopScreen { object Feed : DesktopScreen() @@ -147,7 +131,8 @@ fun main() = ) var showComposeDialog by remember { mutableStateOf(false) } var replyToNote by remember { mutableStateOf(null) } - var currentScreen by remember { mutableStateOf(DesktopScreen.Feed) } + val deckState = remember { DeckState().also { it.load() } } + var showAddColumnDialog by remember { mutableStateOf(false) } Window( onCloseRequest = ::exitApplication, @@ -175,7 +160,7 @@ fun main() = } else { KeyShortcut(Key.Comma, ctrl = true) }, - onClick = { currentScreen = DesktopScreen.Settings }, + onClick = { deckState.addColumn(DeckColumnType.Settings) }, ) Separator() Item( @@ -212,9 +197,102 @@ fun main() = ) } Menu("View") { - Item("Feed", onClick = { }) - Item("Messages", onClick = { }) - Item("Notifications", onClick = { }) + Item( + "Add Column", + shortcut = + if (isMacOS) { + KeyShortcut(Key.T, meta = true) + } else { + KeyShortcut(Key.T, ctrl = true) + }, + onClick = { showAddColumnDialog = true }, + ) + Item( + "Close Column", + shortcut = + if (isMacOS) { + KeyShortcut(Key.W, meta = true) + } else { + KeyShortcut(Key.W, ctrl = true) + }, + onClick = { + val cols = deckState.columns.value + val idx = deckState.focusedColumnIndex.value + if (cols.size > 1 && idx in cols.indices) { + deckState.removeColumn(cols[idx].id) + } + }, + ) + Item( + "Move Column Left", + shortcut = + if (isMacOS) { + KeyShortcut(Key.DirectionLeft, meta = true, shift = true) + } else { + KeyShortcut(Key.DirectionLeft, ctrl = true, shift = true) + }, + onClick = { + val idx = deckState.focusedColumnIndex.value + if (idx > 0) { + deckState.moveColumn(idx, idx - 1) + deckState.focusColumn(idx - 1) + } + }, + ) + Item( + "Move Column Right", + shortcut = + if (isMacOS) { + KeyShortcut(Key.DirectionRight, meta = true, shift = true) + } else { + KeyShortcut(Key.DirectionRight, ctrl = true, shift = true) + }, + onClick = { + val idx = deckState.focusedColumnIndex.value + val size = deckState.columns.value.size + if (idx < size - 1) { + deckState.moveColumn(idx, idx + 1) + deckState.focusColumn(idx + 1) + } + }, + ) + Separator() + // Focus column by index (Cmd/Ctrl+1..9) + val columnKeys = + listOf( + Key.One, + Key.Two, + Key.Three, + Key.Four, + Key.Five, + Key.Six, + Key.Seven, + Key.Eight, + Key.Nine, + ) + columnKeys.forEachIndexed { i, key -> + Item( + "Column ${i + 1}", + shortcut = + if (isMacOS) { + KeyShortcut(key, meta = true) + } else { + KeyShortcut(key, ctrl = true) + }, + onClick = { deckState.focusColumn(i) }, + ) + } + Separator() + Menu("Add Column...") { + Item("Home Feed", onClick = { deckState.addColumn(DeckColumnType.HomeFeed) }) + Item("Notifications", onClick = { deckState.addColumn(DeckColumnType.Notifications) }) + Item("Messages", onClick = { deckState.addColumn(DeckColumnType.Messages) }) + Item("Search", onClick = { deckState.addColumn(DeckColumnType.Search) }) + Item("Reads", onClick = { deckState.addColumn(DeckColumnType.Reads) }) + Item("Bookmarks", onClick = { deckState.addColumn(DeckColumnType.Bookmarks) }) + Item("Global Feed", onClick = { deckState.addColumn(DeckColumnType.GlobalFeed) }) + Item("Profile", onClick = { deckState.addColumn(DeckColumnType.MyProfile) }) + } } Menu("Help") { Item("About Amethyst", onClick = { }) @@ -223,9 +301,9 @@ fun main() = } App( - currentScreen = currentScreen, - onScreenChange = { currentScreen = it }, + deckState = deckState, showComposeDialog = showComposeDialog, + showAddColumnDialog = showAddColumnDialog, onShowComposeDialog = { showComposeDialog = true }, onShowReplyDialog = { event -> replyToNote = event @@ -235,6 +313,8 @@ fun main() = showComposeDialog = false replyToNote = null }, + onDismissAddColumnDialog = { showAddColumnDialog = false }, + onShowAddColumnDialog = { showAddColumnDialog = true }, replyToNote = replyToNote, ) } @@ -242,12 +322,14 @@ fun main() = @Composable fun App( - currentScreen: DesktopScreen, - onScreenChange: (DesktopScreen) -> Unit, + deckState: DeckState, showComposeDialog: Boolean, + showAddColumnDialog: Boolean, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, onDismissComposeDialog: () -> Unit, + onDismissAddColumnDialog: () -> Unit, + onShowAddColumnDialog: () -> Unit, replyToNote: com.vitorpamplona.quartz.nip01Core.core.Event?, ) { val relayManager = remember { DesktopRelayConnectionManager() } @@ -329,7 +411,7 @@ fun App( is AccountState.LoggedOut -> { LoginScreen( accountManager = accountManager, - onLoginSuccess = { onScreenChange(DesktopScreen.Feed) }, + onLoginSuccess = { }, ) } @@ -343,16 +425,17 @@ fun App( } MainContent( - currentScreen = currentScreen, - onScreenChange = onScreenChange, + deckState = deckState, relayManager = relayManager, localCache = localCache, accountManager = accountManager, account = account, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, + appScope = scope, onShowComposeDialog = onShowComposeDialog, onShowReplyDialog = onShowReplyDialog, + onShowAddColumnDialog = onShowAddColumnDialog, ) // Compose dialog @@ -364,6 +447,17 @@ fun App( replyTo = replyToNote, ) } + + // Add column dialog + if (showAddColumnDialog) { + AddColumnDialog( + onDismiss = onDismissAddColumnDialog, + onAdd = { type -> + deckState.addColumn(type) + onDismissAddColumnDialog() + }, + ) + } } } } @@ -372,16 +466,17 @@ fun App( @Composable fun MainContent( - currentScreen: DesktopScreen, - onScreenChange: (DesktopScreen) -> Unit, + deckState: DeckState, relayManager: DesktopRelayConnectionManager, localCache: DesktopLocalCache, accountManager: AccountManager, account: AccountState.LoggedIn, nwcConnection: Nip47WalletConnect.Nip47URINorm?, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, + appScope: CoroutineScope, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, + onShowAddColumnDialog: () -> Unit, ) { val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() @@ -402,227 +497,27 @@ fun MainContent( Box(Modifier.fillMaxSize()) { Row(Modifier.fillMaxSize()) { - // Sidebar Navigation - NavigationRail( - modifier = Modifier.width(80.dp).fillMaxHeight(), - containerColor = MaterialTheme.colorScheme.surfaceVariant, - ) { - Spacer(Modifier.height(16.dp)) - - NavigationRailItem( - icon = { Icon(Icons.Default.Home, contentDescription = "Feed") }, - label = { Text("Feed") }, - selected = currentScreen == DesktopScreen.Feed, - onClick = { onScreenChange(DesktopScreen.Feed) }, - ) - - NavigationRailItem( - icon = { Icon(Icons.AutoMirrored.Filled.Article, contentDescription = "Reads") }, - label = { Text("Reads") }, - selected = currentScreen == DesktopScreen.Reads, - onClick = { onScreenChange(DesktopScreen.Reads) }, - ) - - NavigationRailItem( - icon = { Icon(Icons.Default.Search, contentDescription = "Search") }, - label = { Text("Search") }, - selected = currentScreen == DesktopScreen.Search, - onClick = { onScreenChange(DesktopScreen.Search) }, - ) - - NavigationRailItem( - icon = { Icon(com.vitorpamplona.amethyst.commons.icons.Bookmark, contentDescription = "Bookmarks") }, - label = { Text("Bookmarks") }, - selected = currentScreen == DesktopScreen.Bookmarks, - onClick = { onScreenChange(DesktopScreen.Bookmarks) }, - ) - - NavigationRailItem( - icon = { Icon(Icons.Default.Email, contentDescription = "Messages") }, - label = { Text("DMs") }, - selected = currentScreen == DesktopScreen.Messages, - onClick = { onScreenChange(DesktopScreen.Messages) }, - ) - - NavigationRailItem( - icon = { Icon(Icons.Default.Notifications, contentDescription = "Notifications") }, - label = { Text("Alerts") }, - selected = currentScreen == DesktopScreen.Notifications, - onClick = { onScreenChange(DesktopScreen.Notifications) }, - ) - - NavigationRailItem( - icon = { Icon(Icons.Default.Person, contentDescription = "Profile") }, - label = { Text("Profile") }, - selected = currentScreen == DesktopScreen.MyProfile || currentScreen is DesktopScreen.UserProfile, - onClick = { onScreenChange(DesktopScreen.MyProfile) }, - ) - - Spacer(Modifier.weight(1f)) - - HorizontalDivider(Modifier.padding(horizontal = 16.dp)) - - NavigationRailItem( - icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") }, - label = { Text("Settings") }, - selected = currentScreen == DesktopScreen.Settings, - onClick = { onScreenChange(DesktopScreen.Settings) }, - ) - - Spacer(Modifier.height(16.dp)) - } + DeckSidebar( + onAddColumn = onShowAddColumnDialog, + onOpenSettings = { deckState.addColumn(DeckColumnType.Settings) }, + ) VerticalDivider() - // Main Content - Box( - modifier = Modifier.weight(1f).fillMaxHeight().padding(24.dp), - ) { - when (currentScreen) { - DesktopScreen.Feed -> { - FeedScreen( - relayManager = relayManager, - localCache = localCache, - account = account, - nwcConnection = nwcConnection, - subscriptionsCoordinator = subscriptionsCoordinator, - onCompose = onShowComposeDialog, - onNavigateToProfile = { pubKeyHex -> - onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) - }, - onNavigateToThread = { noteId -> - onScreenChange(DesktopScreen.Thread(noteId)) - }, - onZapFeedback = onZapFeedback, - ) - } - - DesktopScreen.Reads -> { - ReadsScreen( - relayManager = relayManager, - localCache = localCache, - account = account, - onNavigateToProfile = { pubKeyHex -> - onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) - }, - onNavigateToArticle = { noteId -> - onScreenChange(DesktopScreen.Thread(noteId)) - }, - ) - } - - DesktopScreen.Search -> { - SearchScreen( - localCache = localCache, - relayManager = relayManager, - subscriptionsCoordinator = subscriptionsCoordinator, - onNavigateToProfile = { pubKeyHex -> - onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) - }, - onNavigateToThread = { noteId -> - onScreenChange(DesktopScreen.Thread(noteId)) - }, - ) - } - - DesktopScreen.Bookmarks -> { - BookmarksScreen( - relayManager = relayManager, - localCache = localCache, - account = account, - nwcConnection = nwcConnection, - subscriptionsCoordinator = subscriptionsCoordinator, - onNavigateToProfile = { pubKeyHex -> - onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) - }, - onNavigateToThread = { noteId -> - onScreenChange(DesktopScreen.Thread(noteId)) - }, - onZapFeedback = onZapFeedback, - ) - } - - DesktopScreen.Messages -> { - val dmSendTracker = - remember(relayManager) { - DmSendTracker(relayManager.client) - } - val iAccount = - remember(account, localCache, relayManager, dmSendTracker) { - DesktopIAccount(account, localCache, relayManager, dmSendTracker, scope) - } - DesktopMessagesScreen( - account = iAccount, - cacheProvider = localCache, - onNavigateToProfile = { pubKeyHex -> - onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) - }, - ) - } - - DesktopScreen.Notifications -> { - NotificationsScreen(relayManager, account, subscriptionsCoordinator) - } - - DesktopScreen.MyProfile -> { - UserProfileScreen( - pubKeyHex = account.pubKeyHex, - relayManager = relayManager, - localCache = localCache, - account = account, - nwcConnection = nwcConnection, - subscriptionsCoordinator = subscriptionsCoordinator, - onBack = { onScreenChange(DesktopScreen.Feed) }, - onCompose = onShowComposeDialog, - onNavigateToProfile = { pubKeyHex -> - onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) - }, - onZapFeedback = onZapFeedback, - ) - } - - is DesktopScreen.UserProfile -> { - UserProfileScreen( - pubKeyHex = currentScreen.pubKeyHex, - relayManager = relayManager, - localCache = localCache, - account = account, - nwcConnection = nwcConnection, - subscriptionsCoordinator = subscriptionsCoordinator, - onBack = { onScreenChange(DesktopScreen.Feed) }, - onCompose = onShowComposeDialog, - onNavigateToProfile = { pubKeyHex -> - onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) - }, - onZapFeedback = onZapFeedback, - ) - } - - is DesktopScreen.Thread -> { - ThreadScreen( - noteId = currentScreen.noteId, - relayManager = relayManager, - localCache = localCache, - account = account, - nwcConnection = nwcConnection, - subscriptionsCoordinator = subscriptionsCoordinator, - onBack = { onScreenChange(DesktopScreen.Feed) }, - onNavigateToProfile = { pubKeyHex -> - onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) - }, - onNavigateToThread = { noteId -> - onScreenChange(DesktopScreen.Thread(noteId)) - }, - onZapFeedback = onZapFeedback, - onReply = onShowReplyDialog, - ) - } - - DesktopScreen.Settings -> { - RelaySettingsScreen(relayManager, account, accountManager) - } - } - } + DeckLayout( + deckState = deckState, + relayManager = relayManager, + localCache = localCache, + accountManager = accountManager, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + appScope = appScope, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onZapFeedback = onZapFeedback, + modifier = Modifier.weight(1f), + ) } // Snackbar for zap feedback diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt new file mode 100644 index 000000000..3c4f6628a --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.deck + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +private val COLUMN_OPTIONS = + listOf( + DeckColumnType.HomeFeed, + DeckColumnType.Notifications, + DeckColumnType.Messages, + DeckColumnType.Search, + DeckColumnType.Reads, + DeckColumnType.Bookmarks, + DeckColumnType.GlobalFeed, + DeckColumnType.MyProfile, + ) + +@Composable +fun AddColumnDialog( + onDismiss: () -> Unit, + onAdd: (DeckColumnType) -> Unit, +) { + var hashtagInput by remember { mutableStateOf("") } + var showHashtagInput by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Add Column") }, + text = { + Column { + if (showHashtagInput) { + Text( + "Enter hashtag:", + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = hashtagInput, + onValueChange = { hashtagInput = it.removePrefix("#") }, + label = { Text("Hashtag") }, + placeholder = { Text("bitcoin") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } else { + COLUMN_OPTIONS.forEach { type -> + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { onAdd(type) } + .padding(vertical = 10.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + ) { + Icon( + imageVector = type.icon(), + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(12.dp)) + Text( + type.title(), + style = MaterialTheme.typography.bodyLarge, + ) + } + } + + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { showHashtagInput = true } + .padding(vertical = 10.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = DeckColumnType.Hashtag("").icon(), + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(12.dp)) + Text( + "Hashtag...", + style = MaterialTheme.typography.bodyLarge, + ) + } + } + } + }, + confirmButton = { + if (showHashtagInput) { + Button( + onClick = { + if (hashtagInput.isNotBlank()) { + onAdd(DeckColumnType.Hashtag(hashtagInput.trim())) + } + }, + enabled = hashtagInput.isNotBlank(), + ) { + Text("Add") + } + } + }, + dismissButton = { + TextButton(onClick = { + if (showHashtagInput) { + showHashtagInput = false + } else { + onDismiss() + } + }) { + Text(if (showHashtagInput) "Back" else "Cancel") + } + }, + ) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt new file mode 100644 index 000000000..91ac19edb --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.deck + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Article +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Public +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Tag +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +@Composable +fun ColumnHeader( + column: DeckColumn, + canClose: Boolean, + hasBackStack: Boolean, + onBack: () -> Unit, + onClose: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .height(40.dp) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (hasBackStack) { + IconButton(onClick = onBack, modifier = Modifier.size(28.dp)) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.width(4.dp)) + } + + Icon( + imageVector = column.type.icon(), + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.width(8.dp)) + + Text( + text = column.type.title(), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + + if (canClose) { + IconButton(onClick = onClose, modifier = Modifier.size(28.dp)) { + Icon( + Icons.Default.Close, + contentDescription = "Close column", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +fun DeckColumnType.icon(): ImageVector = + when (this) { + DeckColumnType.HomeFeed -> Icons.Default.Home + DeckColumnType.Notifications -> Icons.Default.Notifications + DeckColumnType.Messages -> Icons.Default.Email + DeckColumnType.Search -> Icons.Default.Search + DeckColumnType.Reads -> Icons.AutoMirrored.Filled.Article + DeckColumnType.Bookmarks -> com.vitorpamplona.amethyst.commons.icons.Bookmark + DeckColumnType.GlobalFeed -> Icons.Default.Public + DeckColumnType.MyProfile -> Icons.Default.Person + DeckColumnType.Settings -> Icons.Default.Settings + is DeckColumnType.Profile -> Icons.Default.Person + is DeckColumnType.Thread -> Icons.Default.Home + is DeckColumnType.Hashtag -> Icons.Default.Tag + } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt new file mode 100644 index 000000000..bdcdee177 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -0,0 +1,365 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.deck + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.desktop.DesktopScreen +import com.vitorpamplona.amethyst.desktop.RelaySettingsScreen +import com.vitorpamplona.amethyst.desktop.account.AccountManager +import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator +import com.vitorpamplona.amethyst.desktop.ui.BookmarksScreen +import com.vitorpamplona.amethyst.desktop.ui.FeedScreen +import com.vitorpamplona.amethyst.desktop.ui.NotificationsScreen +import com.vitorpamplona.amethyst.desktop.ui.ReadsScreen +import com.vitorpamplona.amethyst.desktop.ui.SearchScreen +import com.vitorpamplona.amethyst.desktop.ui.ThreadScreen +import com.vitorpamplona.amethyst.desktop.ui.UserProfileScreen +import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback +import com.vitorpamplona.amethyst.desktop.ui.chats.DesktopMessagesScreen +import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow + +class ColumnNavigationState { + private val _stack = MutableStateFlow>(emptyList()) + val stack = _stack + + fun push(screen: DesktopScreen) { + _stack.value = _stack.value + screen + } + + fun pop(): Boolean { + if (_stack.value.isEmpty()) return false + _stack.value = _stack.value.dropLast(1) + return true + } + + fun clear() { + _stack.value = emptyList() + } +} + +@Composable +fun DeckColumnContainer( + column: DeckColumn, + canClose: Boolean, + onClose: () -> Unit, + relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, + accountManager: AccountManager, + account: AccountState.LoggedIn, + nwcConnection: Nip47URINorm?, + subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, + appScope: CoroutineScope, + onShowComposeDialog: () -> Unit, + onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, + onZapFeedback: (ZapFeedback) -> Unit, + modifier: Modifier = Modifier, +) { + val navState = remember(column.id) { ColumnNavigationState() } + val navStack by navState.stack.collectAsState() + val currentOverlay = navStack.lastOrNull() + + Column( + modifier = + modifier + .width(column.width.dp) + .fillMaxHeight(), + ) { + ColumnHeader( + column = column, + canClose = canClose, + hasBackStack = navStack.isNotEmpty(), + onBack = { navState.pop() }, + onClose = onClose, + ) + + HorizontalDivider() + + Box( + modifier = Modifier.fillMaxSize().padding(12.dp), + ) { + if (currentOverlay != null) { + OverlayContent( + screen = currentOverlay, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onZapFeedback = onZapFeedback, + onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, + onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, + onBack = { navState.pop() }, + ) + } else { + RootContent( + columnType = column.type, + relayManager = relayManager, + localCache = localCache, + accountManager = accountManager, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + appScope = appScope, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onZapFeedback = onZapFeedback, + onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, + onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, + ) + } + } + } +} + +@Composable +private fun RootContent( + columnType: DeckColumnType, + relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, + accountManager: AccountManager, + account: AccountState.LoggedIn, + nwcConnection: Nip47URINorm?, + subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, + appScope: CoroutineScope, + onShowComposeDialog: () -> Unit, + onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, + onZapFeedback: (ZapFeedback) -> Unit, + onNavigateToProfile: (String) -> Unit, + onNavigateToThread: (String) -> Unit, +) { + val scope = rememberCoroutineScope() + + when (columnType) { + DeckColumnType.HomeFeed -> { + FeedScreen( + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + onCompose = onShowComposeDialog, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + onZapFeedback = onZapFeedback, + ) + } + + DeckColumnType.Notifications -> { + NotificationsScreen(relayManager, account, subscriptionsCoordinator) + } + + DeckColumnType.Messages -> { + val dmSendTracker = + remember(relayManager) { + DmSendTracker(relayManager.client) + } + val iAccount = + remember(account, localCache, relayManager, dmSendTracker) { + DesktopIAccount(account, localCache, relayManager, dmSendTracker, appScope) + } + DesktopMessagesScreen( + account = iAccount, + cacheProvider = localCache, + onNavigateToProfile = onNavigateToProfile, + ) + } + + DeckColumnType.Search -> { + SearchScreen( + localCache = localCache, + relayManager = relayManager, + subscriptionsCoordinator = subscriptionsCoordinator, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + ) + } + + DeckColumnType.Reads -> { + ReadsScreen( + relayManager = relayManager, + localCache = localCache, + account = account, + onNavigateToProfile = onNavigateToProfile, + onNavigateToArticle = onNavigateToThread, + ) + } + + DeckColumnType.Bookmarks -> { + BookmarksScreen( + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + onZapFeedback = onZapFeedback, + ) + } + + DeckColumnType.GlobalFeed -> { + FeedScreen( + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + onCompose = onShowComposeDialog, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + onZapFeedback = onZapFeedback, + ) + } + + DeckColumnType.MyProfile -> { + UserProfileScreen( + pubKeyHex = account.pubKeyHex, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + onBack = {}, + onCompose = onShowComposeDialog, + onNavigateToProfile = onNavigateToProfile, + onZapFeedback = onZapFeedback, + ) + } + + DeckColumnType.Settings -> { + RelaySettingsScreen(relayManager, account, accountManager) + } + + is DeckColumnType.Profile -> { + UserProfileScreen( + pubKeyHex = columnType.pubKeyHex, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + onBack = {}, + onCompose = onShowComposeDialog, + onNavigateToProfile = onNavigateToProfile, + onZapFeedback = onZapFeedback, + ) + } + + is DeckColumnType.Thread -> { + ThreadScreen( + noteId = columnType.noteId, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + onBack = {}, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + onZapFeedback = onZapFeedback, + onReply = onShowReplyDialog, + ) + } + + is DeckColumnType.Hashtag -> { + SearchScreen( + localCache = localCache, + relayManager = relayManager, + subscriptionsCoordinator = subscriptionsCoordinator, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + ) + } + } +} + +@Composable +private fun OverlayContent( + screen: DesktopScreen, + relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, + account: AccountState.LoggedIn, + nwcConnection: Nip47URINorm?, + subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, + onShowComposeDialog: () -> Unit, + onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, + onZapFeedback: (ZapFeedback) -> Unit, + onNavigateToProfile: (String) -> Unit, + onNavigateToThread: (String) -> Unit, + onBack: () -> Unit, +) { + when (screen) { + is DesktopScreen.UserProfile -> { + UserProfileScreen( + pubKeyHex = screen.pubKeyHex, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + onBack = onBack, + onCompose = onShowComposeDialog, + onNavigateToProfile = onNavigateToProfile, + onZapFeedback = onZapFeedback, + ) + } + + is DesktopScreen.Thread -> { + ThreadScreen( + noteId = screen.noteId, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + onBack = onBack, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + onZapFeedback = onZapFeedback, + onReply = onShowReplyDialog, + ) + } + + else -> {} + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt new file mode 100644 index 000000000..9817782f7 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.deck + +import java.util.UUID + +sealed class DeckColumnType { + object HomeFeed : DeckColumnType() + + object Notifications : DeckColumnType() + + object Messages : DeckColumnType() + + object Search : DeckColumnType() + + object Reads : DeckColumnType() + + object Bookmarks : DeckColumnType() + + object GlobalFeed : DeckColumnType() + + object MyProfile : DeckColumnType() + + object Settings : DeckColumnType() + + data class Profile( + val pubKeyHex: String, + ) : DeckColumnType() + + data class Thread( + val noteId: String, + ) : DeckColumnType() + + data class Hashtag( + val tag: String, + ) : DeckColumnType() + + fun title(): String = + when (this) { + HomeFeed -> "Home" + Notifications -> "Notifications" + Messages -> "Messages" + Search -> "Search" + Reads -> "Reads" + Bookmarks -> "Bookmarks" + GlobalFeed -> "Global" + MyProfile -> "Profile" + Settings -> "Settings" + is Profile -> "Profile" + is Thread -> "Thread" + is Hashtag -> "#$tag" + } + + fun typeKey(): String = + when (this) { + HomeFeed -> "home" + Notifications -> "notifications" + Messages -> "messages" + Search -> "search" + Reads -> "reads" + Bookmarks -> "bookmarks" + GlobalFeed -> "global" + MyProfile -> "my_profile" + Settings -> "settings" + is Profile -> "profile" + is Thread -> "thread" + is Hashtag -> "hashtag" + } +} + +data class DeckColumn( + val id: String = UUID.randomUUID().toString(), + val type: DeckColumnType, + val width: Float = 400f, +) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt new file mode 100644 index 000000000..7ed4473d6 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.deck + +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.desktop.account.AccountManager +import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator +import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm +import kotlinx.coroutines.CoroutineScope +import java.awt.Cursor + +@Composable +fun DeckLayout( + deckState: DeckState, + relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, + accountManager: AccountManager, + account: AccountState.LoggedIn, + nwcConnection: Nip47URINorm?, + subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, + appScope: CoroutineScope, + onShowComposeDialog: () -> Unit, + onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, + onZapFeedback: (ZapFeedback) -> Unit, + modifier: Modifier = Modifier, +) { + val columns by deckState.columns.collectAsState() + val scrollState = rememberScrollState() + + Row( + modifier = modifier.fillMaxSize().horizontalScroll(scrollState), + ) { + columns.forEachIndexed { index, column -> + if (index > 0) { + DraggableDivider( + onDrag = { delta -> + val leftCol = columns[index - 1] + val rightCol = columns[index] + deckState.updateColumnWidth(leftCol.id, leftCol.width + delta) + deckState.updateColumnWidth(rightCol.id, rightCol.width - delta) + }, + ) + } + + DeckColumnContainer( + column = column, + canClose = columns.size > 1, + onClose = { deckState.removeColumn(column.id) }, + relayManager = relayManager, + localCache = localCache, + accountManager = accountManager, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + appScope = appScope, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onZapFeedback = onZapFeedback, + ) + } + } +} + +@Composable +private fun DraggableDivider(onDrag: (Float) -> Unit) { + Box( + modifier = + Modifier + .width(4.dp) + .fillMaxHeight() + .pointerHoverIcon(PointerIcon(Cursor(Cursor.E_RESIZE_CURSOR))) + .pointerInput(Unit) { + detectDragGestures { change, dragAmount -> + change.consume() + onDrag(dragAmount.x / density) + } + }, + ) { + VerticalDivider( + color = MaterialTheme.colorScheme.outlineVariant, + ) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt new file mode 100644 index 000000000..912042a1b --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.deck + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun DeckSidebar( + onAddColumn: () -> Unit, + onOpenSettings: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier + .width(48.dp) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top, + ) { + Text( + "A", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + fontSize = 20.sp, + ) + + Spacer(Modifier.size(16.dp)) + + IconButton(onClick = onAddColumn) { + Icon( + Icons.Default.Add, + contentDescription = "Add Column", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(Modifier.weight(1f)) + + IconButton(onClick = onOpenSettings) { + Icon( + Icons.Default.Settings, + contentDescription = "Settings", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt new file mode 100644 index 000000000..aa4370177 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.deck + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.amethyst.desktop.DesktopPreferences +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class DeckState { + private val _columns = MutableStateFlow(DEFAULT_COLUMNS) + val columns: StateFlow> = _columns.asStateFlow() + + private val _focusedColumnIndex = MutableStateFlow(0) + val focusedColumnIndex: StateFlow = _focusedColumnIndex.asStateFlow() + + fun addColumn( + type: DeckColumnType, + afterIndex: Int? = null, + ) { + val col = DeckColumn(type = type) + _columns.value = + if (afterIndex != null && afterIndex < _columns.value.size) { + _columns.value.toMutableList().apply { add(afterIndex + 1, col) } + } else { + _columns.value + col + } + save() + } + + fun removeColumn(id: String) { + if (_columns.value.size <= 1) return + _columns.value = _columns.value.filter { it.id != id } + if (_focusedColumnIndex.value >= _columns.value.size) { + _focusedColumnIndex.value = _columns.value.size - 1 + } + save() + } + + fun moveColumn( + fromIndex: Int, + toIndex: Int, + ) { + val list = _columns.value.toMutableList() + if (fromIndex !in list.indices || toIndex !in list.indices) return + val item = list.removeAt(fromIndex) + list.add(toIndex, item) + _columns.value = list + save() + } + + fun updateColumnWidth( + id: String, + width: Float, + ) { + _columns.value = + _columns.value.map { + if (it.id == id) it.copy(width = width.coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)) else it + } + save() + } + + fun focusColumn(index: Int) { + if (index in _columns.value.indices) { + _focusedColumnIndex.value = index + } + } + + fun save() { + try { + val data = + _columns.value.map { col -> + mapOf( + "id" to col.id, + "type" to col.type.typeKey(), + "width" to col.width, + "param" to + when (col.type) { + is DeckColumnType.Profile -> col.type.pubKeyHex + is DeckColumnType.Thread -> col.type.noteId + is DeckColumnType.Hashtag -> col.type.tag + else -> null + }, + ) + } + DesktopPreferences.deckColumns = mapper.writeValueAsString(data) + } catch (_: Exception) { + } + } + + fun load() { + try { + val json = DesktopPreferences.deckColumns + if (json.isBlank()) return + val data: List> = mapper.readValue(json) + val loaded = + data.mapNotNull { entry -> + val type = parseColumnType(entry) ?: return@mapNotNull null + val id = entry["id"] as? String ?: return@mapNotNull null + val width = (entry["width"] as? Number)?.toFloat() ?: 400f + DeckColumn(id = id, type = type, width = width.coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)) + } + if (loaded.isNotEmpty()) { + _columns.value = loaded + } + } catch (_: Exception) { + } + } + + companion object { + const val MIN_COLUMN_WIDTH = 300f + const val MAX_COLUMN_WIDTH = 800f + + val DEFAULT_COLUMNS = + listOf( + DeckColumn(type = DeckColumnType.HomeFeed), + DeckColumn(type = DeckColumnType.Notifications), + DeckColumn(type = DeckColumnType.Messages), + ) + + private val mapper = jacksonObjectMapper() + + private fun parseColumnType(entry: Map): DeckColumnType? { + val typeKey = entry["type"] as? String ?: return null + val param = entry["param"] as? String + return when (typeKey) { + "home" -> DeckColumnType.HomeFeed + "notifications" -> DeckColumnType.Notifications + "messages" -> DeckColumnType.Messages + "search" -> DeckColumnType.Search + "reads" -> DeckColumnType.Reads + "bookmarks" -> DeckColumnType.Bookmarks + "global" -> DeckColumnType.GlobalFeed + "my_profile" -> DeckColumnType.MyProfile + "settings" -> DeckColumnType.Settings + "profile" -> param?.let { DeckColumnType.Profile(it) } + "thread" -> param?.let { DeckColumnType.Thread(it) } + "hashtag" -> param?.let { DeckColumnType.Hashtag(it) } + else -> null + } + } + } +} From d82ace2f63670c3e1025a43589aec917e85a3792 Mon Sep 17 00:00:00 2001 From: M Date: Mon, 2 Mar 2026 13:07:46 +1100 Subject: [PATCH 02/13] feat: Namecoin NIP-05 identity verification via ElectrumX Add censorship-resistant NIP-05 verification using the Namecoin blockchain. Users can set their nip05 field to a .bit domain (e.g. alice@example.bit) or direct Namecoin name (d/example, id/alice) and Amethyst will resolve the pubkey mapping via ElectrumX instead of HTTP. Resolution uses the standard Electrum protocol (scripthash-based lookups): - Build canonical name index script matching ElectrumX-NMC indexing - Query blockchain.scripthash.get_history for the name's tx history - Parse NAME_UPDATE script from the latest transaction output - Extract Nostr pubkey from the name's JSON value Supports both d/ (domain) and id/ (identity) Namecoin namespaces, simple and extended NIP-05-like value formats with relay hints, LRU caching with 1h TTL, and self-signed TLS certificates. New files: - quartz: ElectrumxClient, NamecoinNameResolver, NamecoinLookupCache - amethyst: NamecoinNameService, Nip05NamecoinAdapter, NamecoinVerificationDisplay - docs: namecoin-nip05-design.md - tests: NamecoinNameResolverTest Modified: - Nip05Client: optional namecoinResolver routes .bit to blockchain - AppModules: wire up resolver See docs/namecoin-nip05-design.md for full architecture and protocol details. --- .../com/vitorpamplona/amethyst/AppModules.kt | 7 +- .../service/namecoin/NamecoinNameService.kt | 158 ++++++ .../service/namecoin/Nip05NamecoinAdapter.kt | 62 +++ .../namecoin/NamecoinVerificationDisplay.kt | 259 ++++++++++ .../loggedIn/relays/RelayInformationScreen.kt | 25 +- docs/namecoin-nip05-design.md | 231 +++++++++ .../quartz/nip05/namecoin/ElectrumxClient.kt | 487 ++++++++++++++++++ .../nip05/namecoin/NamecoinLookupCache.kt | 77 +++ .../nip05/namecoin/NamecoinNameResolver.kt | 313 +++++++++++ .../quartz/nip05DnsIdentifiers/Nip05Client.kt | 18 +- .../namecoin/NamecoinNameResolverTest.kt | 281 ++++++++++ 11 files changed, 1908 insertions(+), 10 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/Nip05NamecoinAdapter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/namecoin/NamecoinVerificationDisplay.kt create mode 100644 docs/namecoin-nip05-design.md create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinLookupCache.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolver.kt create mode 100644 quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolverTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 8c13563f3..25ef825e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -142,7 +142,12 @@ class AppModules( // Custom fetcher that considers tor settings and avoids forwarding. val nip05Fetcher = OkHttpNip05Fetcher(roleBasedHttpClientBuilder::okHttpClientForNip05) - val nip05Client = Nip05Client(nip05Fetcher) + val namecoinResolver = + com.vitorpamplona.quartz.nip05.namecoin.NamecoinNameResolver( + com.vitorpamplona.quartz.nip05.namecoin + .ElectrumxClient(), + ) + val nip05Client = Nip05Client(nip05Fetcher, namecoinResolver) // Application-wide block height request cache val otsBlockHeightCache by lazy { OtsBlockHeightCache() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt new file mode 100644 index 000000000..178a755b6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.namecoin + +import com.vitorpamplona.quartz.nip05.namecoin.ElectrumxClient +import com.vitorpamplona.quartz.nip05.namecoin.ElectrumxServer +import com.vitorpamplona.quartz.nip05.namecoin.NamecoinLookupCache +import com.vitorpamplona.quartz.nip05.namecoin.NamecoinNameResolver +import com.vitorpamplona.quartz.nip05.namecoin.NamecoinNostrResult +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +/** + * Application-level singleton for Namecoin name resolution. + * + * Thread-safe, lifecycle-aware, and designed for integration + * into Amethyst's existing `ServiceManager` infrastructure. + */ +class NamecoinNameService private constructor() { + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private val electrumxClient = ElectrumxClient() + private val resolver = NamecoinNameResolver(electrumxClient) + private val cache = NamecoinLookupCache() + + // Custom server list (user-configurable) + private var customServers: List = emptyList() + + companion object { + @Volatile + private var instance: NamecoinNameService? = null + + fun getInstance(): NamecoinNameService = + instance ?: synchronized(this) { + instance ?: NamecoinNameService().also { instance = it } + } + } + + // ── Public API ───────────────────────────────────────────────────── + + /** + * Resolve a Namecoin identifier to a Nostr pubkey. + * + * Returns cached results when available. This is the primary method + * that the search bar and NIP-05 verifier should call. + * + * @param identifier e.g. "alice@example.bit", "id/bob", "example.bit" + * @return [NamecoinNostrResult] or null + */ + suspend fun resolve(identifier: String): NamecoinNostrResult? { + // Check cache first + val cached = cache.get(identifier) + if (cached != null) return cached.result + + // Perform lookup + val result = resolver.resolve(identifier) + cache.put(identifier, result) + return result + } + + /** + * Verify that a Namecoin name maps to the expected pubkey. + * + * This is the Namecoin equivalent of NIP-05 verification: + * given a pubkey from a kind-0 event and a `nip05` value + * ending in `.bit`, check that the Namecoin blockchain + * confirms the mapping. + * + * @param nip05Address The nip05 field value, e.g. "alice@example.bit" + * @param expectedPubkeyHex The pubkey from the kind-0 event + * @return true if the Namecoin name resolves to the expected pubkey + */ + suspend fun verifyNip05( + nip05Address: String, + expectedPubkeyHex: String, + ): Boolean { + if (!NamecoinNameResolver.isNamecoinIdentifier(nip05Address)) return false + val result = resolve(nip05Address) ?: return false + return result.pubkey.equals(expectedPubkeyHex, ignoreCase = true) + } + + /** + * Perform a lookup and emit results via a StateFlow. + * + * Useful for composable UIs that observe resolution state. + */ + fun resolveLive(identifier: String): StateFlow { + val state = MutableStateFlow(NamecoinResolveState.Loading) + scope.launch { + try { + val result = resolve(identifier) + state.value = + if (result != null) { + NamecoinResolveState.Resolved(result) + } else { + NamecoinResolveState.NotFound + } + } catch (e: Exception) { + state.value = NamecoinResolveState.Error(e.message ?: "Unknown error") + } + } + return state + } + + /** + * Configure custom ElectrumX servers. + * + * Users who run their own ElectrumX instance can add it here + * for better privacy and reliability. + */ + fun setCustomServers(servers: List) { + customServers = servers + } + + /** + * Clear the resolution cache. + */ + suspend fun clearCache() = cache.clear() +} + +/** + * Observable state for a Namecoin resolution in progress. + */ +sealed class NamecoinResolveState { + data object Loading : NamecoinResolveState() + + data class Resolved( + val result: NamecoinNostrResult, + ) : NamecoinResolveState() + + data object NotFound : NamecoinResolveState() + + data class Error( + val message: String, + ) : NamecoinResolveState() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/Nip05NamecoinAdapter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/Nip05NamecoinAdapter.kt new file mode 100644 index 000000000..706570c16 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/Nip05NamecoinAdapter.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.namecoin + +import com.vitorpamplona.quartz.nip05.namecoin.NamecoinNameResolver + +/** + * Static adapter for hooking Namecoin into NIP-05 verification. + */ +object Nip05NamecoinAdapter { + /** + * Attempt Namecoin-based NIP-05 verification. + * + * @param nip05Address The `nip05` field from a kind-0 event + * @param expectedPubkeyHex The hex pubkey from the same event + * @return `true` if verified via Namecoin, `false` if Namecoin says + * the mapping is wrong, or `null` if this identifier is not + * a Namecoin identifier (caller should fall through to HTTP NIP-05). + */ + suspend fun tryVerify( + nip05Address: String, + expectedPubkeyHex: String, + ): Boolean? { + if (!NamecoinNameResolver.isNamecoinIdentifier(nip05Address)) { + return null // Not a Namecoin identifier → let caller handle via HTTP + } + return NamecoinNameService.getInstance().verifyNip05(nip05Address, expectedPubkeyHex) + } + + /** + * Attempt to resolve a Namecoin identifier from the search bar. + * + * Called from the search/discovery flow when the user types an + * identifier. Returns the hex pubkey if found, null otherwise. + * + * @param query The user's search input + * @return Pair of (pubkey, relayList) or null + */ + suspend fun tryResolveSearch(query: String): Pair>? { + if (!NamecoinNameResolver.isNamecoinIdentifier(query)) return null + val result = NamecoinNameService.getInstance().resolve(query) ?: return null + return Pair(result.pubkey, result.relays) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/namecoin/NamecoinVerificationDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/namecoin/NamecoinVerificationDisplay.kt new file mode 100644 index 000000000..2942b091f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/namecoin/NamecoinVerificationDisplay.kt @@ -0,0 +1,259 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.namecoin + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.service.namecoin.NamecoinNameService +import com.vitorpamplona.amethyst.service.namecoin.NamecoinResolveState + +/** + * Display a Namecoin-verified identity badge. + * + * Shows the Namecoin name with a blockchain icon when verified. + * Renders nothing if verification fails or is still loading. + * + * @param nip05 The nip05 field value ending in .bit or starting with id/ + * @param expectedPubkeyHex The profile's pubkey to verify against + * @param modifier Standard compose modifier + */ +@Composable +fun NamecoinVerificationDisplay( + nip05: String, + expectedPubkeyHex: String, + modifier: Modifier = Modifier, +) { + var isVerified by remember(nip05, expectedPubkeyHex) { mutableStateOf(null) } + + LaunchedEffect(nip05, expectedPubkeyHex) { + isVerified = NamecoinNameService.getInstance().verifyNip05(nip05, expectedPubkeyHex) + } + + if (isVerified == true) { + NamecoinBadge( + displayName = formatDisplayName(nip05), + namespace = inferNamespace(nip05), + modifier = modifier, + ) + } +} + +/** + * The visual badge component. + * + * Shows: [chain icon] [display name] + * With namespace-appropriate styling. + */ +@Composable +private fun NamecoinBadge( + displayName: String, + namespace: String, // "d/" or "id/" + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.padding(top = 1.dp, bottom = 1.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + ) { + // Blockchain chain-link icon + // In production, replace with a proper vector drawable. + // For now, use a text glyph as placeholder. + Text( + text = "\u26D3", // ⛓ chain link emoji + fontSize = 12.sp, + color = NamecoinColors.chainIcon, + modifier = Modifier.padding(end = 2.dp), + ) + + // Namespace indicator + Text( + text = displayName, + fontSize = 14.sp, + color = NamecoinColors.verifiedText, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +/** + * Composable for the search result when a Namecoin name resolves. + * + * Shows the resolved Namecoin name, the Nostr pubkey, and relay hints. + * Used in the search results list. + */ +@Composable +fun NamecoinSearchResult( + identifier: String, + onProfileClick: (pubkeyHex: String) -> Unit, + modifier: Modifier = Modifier, +) { + val resolveState by NamecoinNameService + .getInstance() + .resolveLive(identifier) + .collectAsState() + + when (val state = resolveState) { + is NamecoinResolveState.Loading -> { + Row( + modifier = modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "\u26D3", // ⛓ + fontSize = 16.sp, + color = NamecoinColors.chainIcon, + ) + Spacer(Modifier.width(8.dp)) + Text( + text = "Resolving $identifier via Namecoin…", + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + is NamecoinResolveState.Resolved -> { + Row( + modifier = + modifier + .clickable { onProfileClick(state.result.pubkey) } + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "\u26D3", // ⛓ + fontSize = 16.sp, + color = NamecoinColors.verified, + ) + Spacer(Modifier.width(8.dp)) + // The resolved identity + Text( + text = formatDisplayName(identifier), + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(Modifier.width(4.dp)) + Text( + text = "(${state.result.pubkey.take(8)}…)", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + is NamecoinResolveState.NotFound -> { + Row( + modifier = modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "No Nostr identity found for $identifier", + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + is NamecoinResolveState.Error -> { + Row( + modifier = modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Namecoin lookup failed: ${state.message}", + fontSize = 14.sp, + color = MaterialTheme.colorScheme.error, + ) + } + } + } +} + +// ── Display Helpers ──────────────────────────────────────────────────── + +/** + * Format a raw identifier for user-friendly display. + * + * "alice@example.bit" → "alice@example.bit" + * "_@example.bit" → "example.bit" (NIP-05 root convention) + * "d/example" → "example.bit" + * "id/alice" → "id/alice" + */ +private fun formatDisplayName(identifier: String): String { + val input = identifier.trim() + + // _@domain.bit → show just domain.bit + if (input.startsWith("_@")) { + return input.removePrefix("_@") + } + + // d/name → name.bit + if (input.startsWith("d/", ignoreCase = true)) { + return input.removePrefix("d/").removePrefix("D/") + ".bit" + } + + // id/name stays as-is + if (input.startsWith("id/", ignoreCase = true)) { + return input + } + + return input +} + +private fun inferNamespace(identifier: String): String { + val input = identifier.trim().lowercase() + return when { + input.startsWith("id/") -> "id/" + else -> "d/" + } +} + +/** + * Color palette for Namecoin verification UI. + */ +private object NamecoinColors { + val chainIcon = Color(0xFF4A90D9) // Namecoin blue + val verified = Color(0xFF2E8B57) // Sea green — distinct from NIP-05 blue + val verifiedText = Color(0xFF4A90D9) // Namecoin blue for the text +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt index 9c88cc3cd..06eb176e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt @@ -72,8 +72,8 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable @@ -95,8 +95,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R -import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.amethyst.commons.util.timeDiffAgoShortish import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled @@ -119,11 +117,13 @@ import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.stats.ErrorDebugMessage import com.vitorpamplona.quartz.nip01Core.relay.client.stats.IRelayDebugMessage import com.vitorpamplona.quartz.nip01Core.relay.client.stats.NoticeDebugMessage import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStat import com.vitorpamplona.quartz.nip01Core.relay.client.stats.SpamDebugMessage +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl @@ -387,16 +387,25 @@ private fun KindChip(kind: Int) { val name = kindDisplayName(kind) val (bg, fg) = when { - kind in setOf(0, 1, 6, 7, 16, 30023) -> + kind in setOf(0, 1, 6, 7, 16, 30023) -> { MaterialTheme.colorScheme.primaryContainer to MaterialTheme.colorScheme.onPrimaryContainer - kind in setOf(3, 10002, 10000, 10001, 10003, 10004, 30000) -> + } + + kind in setOf(3, 10002, 10000, 10001, 10003, 10004, 30000) -> { MaterialTheme.colorScheme.secondaryContainer to MaterialTheme.colorScheme.onSecondaryContainer - kind in setOf(4, 1059, 10050) -> + } + + kind in setOf(4, 1059, 10050) -> { MaterialTheme.colorScheme.tertiaryContainer to MaterialTheme.colorScheme.onTertiaryContainer - kind in setOf(9734, 9735, 9041, 17375, 23194, 23195) -> + } + + kind in setOf(9734, 9735, 9041, 17375, 23194, 23195) -> { MaterialTheme.colorScheme.errorContainer to MaterialTheme.colorScheme.onErrorContainer - else -> + } + + else -> { MaterialTheme.colorScheme.surfaceVariant to MaterialTheme.colorScheme.onSurfaceVariant + } } Surface( shape = RoundedCornerShape(50), diff --git a/docs/namecoin-nip05-design.md b/docs/namecoin-nip05-design.md new file mode 100644 index 000000000..468c9f5f1 --- /dev/null +++ b/docs/namecoin-nip05-design.md @@ -0,0 +1,231 @@ +# Namecoin NIP-05 Resolution — Design Document + +## Overview + +This patch adds Namecoin blockchain-based NIP-05 identity verification to Amethyst. Users can set their `nip05` field to a `.bit` domain (e.g. `alice@example.bit`) or a direct Namecoin name (`d/example`, `id/alice`), and Amethyst will resolve it via the Namecoin blockchain instead of HTTP. + +This is censorship-resistant identity verification: no web server to seize, no DNS to hijack, no TLS certificate to revoke. The name-to-pubkey mapping lives in Namecoin UTXOs. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Amethyst App │ +├─────────────────────────────────────────────────────────────┤ +│ Nip05Client │ +│ ├── HTTP path (existing) ── Nip05Fetcher │ +│ └── Namecoin path (new) ── NamecoinNameResolver │ +│ │ │ +│ NamecoinNameService (singleton) │ │ +│ ├── NamecoinLookupCache │ │ +│ └── Nip05NamecoinAdapter │ │ +│ │ │ +│ UI: NamecoinVerificationDisplay │ │ +│ NamecoinSearchResult │ │ +├────────────────────────────────────┼────────────────────────┤ +│ Quartz Library │ +├────────────────────────────────────┼────────────────────────┤ +│ NamecoinNameResolver │ │ +│ ├── parseIdentifier() │ │ +│ ├── extractFromDomainValue() │ (d/ namespace) │ +│ └── extractFromIdentityValue() │ (id/ namespace) │ +│ │ │ +│ ElectrumxClient │ │ +│ ├── buildNameIndexScript() │ │ +│ ├── electrumScriptHash() │ │ +│ └── parseNameScript() │ │ +│ ▼ │ +│ ┌──────────────────┐ │ +│ │ ElectrumX Server │ │ +│ │ (Namecoin node) │ │ +│ └──────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Layer Separation + +- **`quartz/` (library)** — Protocol-level logic. No Android dependencies. + - `ElectrumxClient` — TCP/TLS connection to ElectrumX, JSON-RPC, script parsing + - `NamecoinNameResolver` — Identifier parsing, value extraction, NIP-05 mapping + - `NamecoinLookupCache` — LRU cache with TTL + - `NamecoinNameResolverTest` — Unit tests for parsing and value extraction + +- **`amethyst/` (app)** — Android integration and UI. + - `NamecoinNameService` — Application singleton, lifecycle management + - `Nip05NamecoinAdapter` — Static bridge for NIP-05 verification hooks + - `NamecoinVerificationDisplay` — Compose UI for verified badge + search results + +## ElectrumX Protocol — How Name Resolution Works + +Namecoin names are stored as UTXOs with `NAME_UPDATE` scripts. The ElectrumX server indexes these by a canonical "name index script hash", allowing lookup via standard Electrum protocol methods. + +### Resolution Steps + +``` +1. Build canonical name index script + OP_NAME_UPDATE(0x53) + push(name_bytes) + push(empty) + OP_2DROP(0x6d) + OP_DROP(0x75) + OP_RETURN(0x6a) + +2. Compute Electrum-style scripthash + SHA-256(script) → reverse bytes → hex encode + +3. Query transaction history + → blockchain.scripthash.get_history(scripthash) + ← [{tx_hash, height}, ...] + +4. Fetch latest transaction (last entry = most recent name update) + → blockchain.transaction.get(tx_hash, verbose=true) + ← {vout: [{scriptPubKey: {hex: "53..."}}]} + +5. Parse NAME_UPDATE script from transaction output + Script: OP_NAME_UPDATE OP_2DROP OP_DROP + Extract: name string + JSON value + +6. Extract Nostr pubkey from the JSON value +``` + +### Why Not `blockchain.name.get_value_proof`? + +The Electrum-NMC fork of ElectrumX advertises a `blockchain.name.get_value_proof` method (protocol v1.4.3), but in practice this method expects a scripthash parameter, not a name string. The scripthash-based approach described above works with both the Namecoin ElectrumX fork and stock ElectrumX pointed at a Namecoin node, as long as the server has a name index. + +## Namecoin Value Formats + +### Domain namespace (`d/`) + +Namecoin `d/` names store domain configuration as JSON. Two Nostr formats are supported: + +**Simple form** — single pubkey for the root domain: +```json +{ + "nostr": "b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9" +} +``` + +**Extended form** — multiple users with relay hints (mirrors NIP-05 JSON structure): +```json +{ + "nostr": { + "names": { + "_": "aaaa...0001", + "alice": "bbbb...0002" + }, + "relays": { + "bbbb...0002": ["wss://relay.example.com"] + } + } +} +``` + +### Identity namespace (`id/`) + +Namecoin `id/` names store personal identity data: + +```json +{ + "nostr": "cccc...0003" +} +``` + +Or with relay hints: +```json +{ + "nostr": { + "pubkey": "dddd...0004", + "relays": ["wss://relay.example.com"] + } +} +``` + +## Identifier Formats + +| User input | Namecoin name | Local part | Namespace | +|---|---|---|---| +| `alice@example.bit` | `d/example` | `alice` | DOMAIN | +| `_@example.bit` | `d/example` | `_` | DOMAIN | +| `example.bit` | `d/example` | `_` | DOMAIN | +| `d/example` | `d/example` | `_` | DOMAIN | +| `id/alice` | `id/alice` | `_` | IDENTITY | + +## NIP-05 Integration + +The integration is minimal and non-invasive: + +1. **`Nip05Client`** gains an optional `namecoinResolver` parameter +2. On `verify()` and `get()`, if the identifier matches `.bit` / `d/` / `id/`, it routes to `NamecoinNameResolver` instead of the HTTP fetcher +3. Non-Namecoin identifiers are completely unaffected +4. **`AppModules`** wires up the resolver at construction time + +## Default ElectrumX Server + +``` +electrumx.testls.space:50002 (TLS, self-signed certificate) +``` + +- ElectrumX 1.16.0, Namecoin chain, protocol 1.4–1.4.3 +- Also available via Tor: `i665jpwsq46zlsdbnj4axgzd3s56uzey5uhotsnxzsknzbn36jaddsid.onion:50002` +- Fallback servers: `ulrichard.ch:50006`, `nmc2.lelux.fi:50006` (currently offline) + +The `trustAllCerts` flag is set for servers with self-signed certificates. Users can configure custom servers via `NamecoinNameService.setCustomServers()`. + +## Caching + +- **LRU cache** with configurable max entries (default 500) and TTL (default 1 hour) +- Cache key is the normalized (lowercased, trimmed) identifier +- Both positive and negative results are cached +- Cache is invalidated on TTL expiry; manual `invalidate()` and `clear()` are available + +## UI Components + +### NamecoinVerificationDisplay +Shows a ⛓ chain-link badge next to profiles verified via Namecoin. Distinct from the standard NIP-05 checkmark — uses Namecoin blue (#4A90D9) and sea green (#2E8B57). + +### NamecoinSearchResult +Search bar integration. When a user types a `.bit` identifier, shows a loading state during resolution, then the resolved pubkey with a clickable profile link. + +## Security Considerations + +- **Self-signed certificates**: The primary ElectrumX server uses a self-signed TLS cert. The `trustAllCerts` option accepts any certificate for that server. This is acceptable because the Namecoin blockchain itself provides the trust anchor — we verify names against on-chain data, not the transport layer. A MITM could return stale data but cannot forge name registrations. +- **Name expiry**: Namecoin names expire after ~36,000 blocks (~250 days) if not renewed. The current implementation does not check expiry. Future work should compare the name's `height` + `expiresIn` against the current block height. +- **Server trust**: The client trusts that the ElectrumX server returns accurate transaction data. For higher assurance, SPV proof verification could be added in the future. + +## Files Changed + +### New files (quartz/) +- `quartz/.../nip05/namecoin/ElectrumxClient.kt` — ElectrumX TCP/TLS client, scripthash-based name resolution +- `quartz/.../nip05/namecoin/NamecoinNameResolver.kt` — Identifier parsing, value extraction +- `quartz/.../nip05/namecoin/NamecoinLookupCache.kt` — LRU cache with TTL +- `quartz/src/jvmTest/.../NamecoinNameResolverTest.kt` — Unit tests + +### New files (amethyst/) +- `amethyst/.../service/namecoin/NamecoinNameService.kt` — App singleton, coroutine scope +- `amethyst/.../service/namecoin/Nip05NamecoinAdapter.kt` — Static bridge for NIP-05 hooks +- `amethyst/.../ui/note/namecoin/NamecoinVerificationDisplay.kt` — Compose UI components + +### Modified files +- `amethyst/.../AppModules.kt` — Wire up `NamecoinNameResolver` into `Nip05Client` +- `quartz/.../nip05DnsIdentifiers/Nip05Client.kt` — Route `.bit` identifiers to Namecoin resolver +- `amethyst/.../relays/RelayInformationScreen.kt` — Import reordering (spotless) + +## Testing + +### Unit tests +```bash +./gradlew :quartz:jvmTest --tests "*NamecoinNameResolverTest*" +``` + +### Manual testing (emulator) +1. Build and install: `./gradlew :amethyst:installFdroidDebug` +2. Search for `m@testls.bit` — should resolve to pubkey `6cdebcca...18667d` +3. Search for `testls.bit` — root domain lookup +4. Search for `d/testls` — direct namespace format + +### Live verification +The name `d/testls` is registered on the Namecoin blockchain (block 551519+, last updated block 814278) with value: +```json +{ + "nostr": { + "names": { + "m": "6cdebccabda1dfa058ab85352a79509b592b2bdfa0370325e28ec1cb4f18667d" + } + } +} +``` diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt new file mode 100644 index 000000000..346e7bb8b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt @@ -0,0 +1,487 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip05.namecoin + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import java.io.BufferedReader +import java.io.InputStreamReader +import java.io.PrintWriter +import java.net.InetSocketAddress +import java.net.Socket +import java.security.MessageDigest +import java.util.concurrent.atomic.AtomicInteger +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLSocketFactory +import javax.net.ssl.TrustManager +import javax.net.ssl.X509TrustManager + +/** + * Result of an ElectrumX name_show query. + * + * Maps to the JSON fields returned by Namecoin Core / Electrum-NMC: + * { "name": "d/example", "value": "{...}", "txid": "abc...", "height": 12345, ... } + */ +@Serializable +data class NameShowResult( + val name: String, + val value: String, + val txid: String? = null, + val height: Int? = null, + val expiresIn: Int? = null, +) + +/** + * Represents a single ElectrumX server endpoint. + */ +data class ElectrumxServer( + val host: String, + val port: Int, + val useSsl: Boolean = true, + /** If true, accept any certificate (self-signed, expired, etc.) */ + val trustAllCerts: Boolean = false, +) + +/** + * Lightweight, query-only ElectrumX client for Namecoin name resolution. + * + * Connects over TCP/TLS to a Namecoin ElectrumX server and resolves + * Namecoin names to their current values using the standard Electrum + * protocol (scripthash-based lookups). Works with both the Namecoin + * ElectrumX fork and stock ElectrumX pointed at a Namecoin node, as + * long as the server has a name index. + * + * Resolution strategy: + * 1. Build a canonical name index script for the identifier + * 2. Compute the Electrum-style scripthash (reversed SHA-256) + * 3. Query `blockchain.scripthash.get_history` to find the latest tx + * 4. Fetch the raw transaction and parse the name value from the script + * + * Usage: + * ``` + * val client = ElectrumxClient() + * val result = client.nameShow("d/example", server) + * ``` + */ +class ElectrumxClient( + private val connectTimeoutMs: Long = 10_000L, + private val readTimeoutMs: Long = 15_000L, +) { + private val json = + Json { + ignoreUnknownKeys = true + isLenient = true + } + private val requestId = AtomicInteger(0) + private val mutex = Mutex() + + companion object { + /** Well-known public Namecoin ElectrumX servers. */ + val DEFAULT_SERVERS = + listOf( + ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, trustAllCerts = true), + ElectrumxServer("ulrichard.ch", 50006, useSsl = true), + ElectrumxServer("nmc2.lelux.fi", 50006, useSsl = true), + ) + + private const val PROTOCOL_VERSION = "1.4" + + // Namecoin script opcodes + private const val OP_NAME_UPDATE: Byte = 0x53 // OP_3 repurposed by Namecoin + private const val OP_2DROP: Byte = 0x6d + private const val OP_DROP: Byte = 0x75 + private const val OP_RETURN: Byte = 0x6a + private const val OP_PUSHDATA1: Byte = 0x4c + private const val OP_PUSHDATA2: Byte = 0x4d + } + + /** + * Perform a name_show lookup against the given ElectrumX server. + * + * Uses the scripthash-based approach: computes the name's canonical + * index script hash, queries transaction history, and parses the + * name value from the latest transaction's output script. + * + * @param identifier Full Namecoin name, e.g. "d/example" or "id/alice" + * @param server ElectrumX server to query + * @return [NameShowResult] on success, null if the name does not exist + * or the server is unreachable + */ + suspend fun nameShow( + identifier: String, + server: ElectrumxServer = DEFAULT_SERVERS.first(), + ): NameShowResult? = + withContext(Dispatchers.IO) { + mutex.withLock { + try { + connectAndQuery(identifier, server) + } catch (e: Exception) { + // Log but don't crash — callers handle null gracefully. + e.printStackTrace() + null + } + } + } + + /** + * Try each default server in order until one succeeds. + */ + suspend fun nameShowWithFallback(identifier: String): NameShowResult? { + for (server in DEFAULT_SERVERS) { + val result = nameShow(identifier, server) + if (result != null) return result + } + return null + } + + // ── internals ────────────────────────────────────────────────────── + + private fun connectAndQuery( + identifier: String, + server: ElectrumxServer, + ): NameShowResult? { + val socket = createSocket(server) + socket.soTimeout = readTimeoutMs.toInt() + val writer = PrintWriter(socket.getOutputStream(), true) + val reader = BufferedReader(InputStreamReader(socket.getInputStream())) + + try { + // 1. Negotiate protocol version + val versionReq = buildRpcRequest("server.version", listOf("AmethystNMC/0.1", PROTOCOL_VERSION)) + writer.println(versionReq) + reader.readLine() // consume version response + + // 2. Compute the canonical name index scripthash + val nameScript = buildNameIndexScript(identifier.toByteArray(Charsets.US_ASCII)) + val scriptHash = electrumScriptHash(nameScript) + + // 3. Get transaction history for this name + val historyReq = buildRpcRequest("blockchain.scripthash.get_history", listOf(scriptHash)) + writer.println(historyReq) + val historyResponse = reader.readLine() ?: return null + val historyEntries = parseHistoryResponse(historyResponse) ?: return null + if (historyEntries.isEmpty()) return null + + // 4. Get the latest transaction (last entry = most recent update) + val latestEntry = historyEntries.last() + val txHash = latestEntry.first + val height = latestEntry.second + + val txReq = buildRpcRequest("blockchain.transaction.get", listOf(txHash, true)) + writer.println(txReq) + val txResponse = reader.readLine() ?: return null + + // 5. Parse the name value from the transaction + return parseNameFromTransaction(identifier, txHash, height, txResponse) + } finally { + runCatching { writer.close() } + runCatching { reader.close() } + runCatching { socket.close() } + } + } + + /** + * Build the canonical script used by ElectrumX to index Namecoin names. + * + * Format: OP_NAME_UPDATE OP_2DROP OP_DROP OP_RETURN + * + * This matches the `build_name_index_script` method in the Namecoin + * ElectrumX fork (electrumx/lib/coins.py). + */ + private fun buildNameIndexScript(nameBytes: ByteArray): ByteArray { + val result = mutableListOf() + result.add(OP_NAME_UPDATE) + result.addAll(pushData(nameBytes).toList()) + result.addAll(pushData(byteArrayOf()).toList()) // empty value + result.add(OP_2DROP) + result.add(OP_DROP) + result.add(OP_RETURN) + return result.toByteArray() + } + + /** + * Bitcoin-style push data encoding. + */ + private fun pushData(data: ByteArray): ByteArray { + val len = data.size + return when { + len < 0x4c -> { + byteArrayOf(len.toByte()) + data + } + + len <= 0xff -> { + byteArrayOf(OP_PUSHDATA1, len.toByte()) + data + } + + else -> { + val lenBytes = byteArrayOf((len and 0xff).toByte(), ((len shr 8) and 0xff).toByte()) + byteArrayOf(OP_PUSHDATA2) + lenBytes + data + } + } + } + + /** + * Electrum protocol scripthash: SHA-256 of the script, byte-reversed, hex-encoded. + */ + private fun electrumScriptHash(script: ByteArray): String { + val digest = MessageDigest.getInstance("SHA-256").digest(script) + return digest.reversedArray().joinToString("") { "%02x".format(it) } + } + + /** + * Parse the history response into a list of (txHash, height) pairs. + */ + private fun parseHistoryResponse(raw: String): List>? { + val envelope = json.parseToJsonElement(raw).jsonObject + val error = envelope["error"] + if (error != null && error !is kotlinx.serialization.json.JsonNull) return null + + val result = envelope["result"]?.jsonArray ?: return null + return result.mapNotNull { entry -> + val obj = entry.jsonObject + val txHash = obj["tx_hash"]?.jsonPrimitive?.content ?: return@mapNotNull null + val height = obj["height"]?.jsonPrimitive?.int ?: return@mapNotNull null + txHash to height + } + } + + /** + * Parse a Namecoin name and value from a verbose transaction response. + * + * Scans each output for a NAME_UPDATE script (starts with OP_3 = 0x53), + * then extracts the name and value from the script's push data. + */ + private fun parseNameFromTransaction( + identifier: String, + txHash: String, + height: Int, + raw: String, + ): NameShowResult? { + val envelope = json.parseToJsonElement(raw).jsonObject + val error = envelope["error"] + if (error != null && error !is kotlinx.serialization.json.JsonNull) return null + + val result = envelope["result"]?.jsonObject ?: return null + val vouts = result["vout"]?.jsonArray ?: return null + + for (vout in vouts) { + val scriptHex = + vout.jsonObject["scriptPubKey"] + ?.jsonObject + ?.get("hex") + ?.jsonPrimitive + ?.content + ?: continue + + // NAME_UPDATE scripts start with OP_3 (0x53) + if (!scriptHex.startsWith("53")) continue + + val scriptBytes = hexToBytes(scriptHex) + val parsed = parseNameScript(scriptBytes) ?: continue + + // Verify this is the name we're looking for + if (parsed.first == identifier) { + return NameShowResult( + name = parsed.first, + value = parsed.second, + txid = txHash, + height = height, + ) + } + } + + return null + } + + /** + * Parse a NAME_UPDATE script to extract the name and value. + * + * Script format: OP_NAME_UPDATE OP_2DROP OP_DROP + * + * @return Pair of (name, value) as strings, or null if parsing fails + */ + private fun parseNameScript(script: ByteArray): Pair? { + if (script.isEmpty() || script[0] != OP_NAME_UPDATE) return null + + var pos = 1 + + // Read name + val (nameBytes, newPos1) = readPushData(script, pos) ?: return null + pos = newPos1 + + // Read value + val (valueBytes, _) = readPushData(script, pos) ?: return null + + val name = String(nameBytes, Charsets.US_ASCII) + val value = String(valueBytes, Charsets.UTF_8) + return name to value + } + + /** + * Read a push-data encoded byte sequence from the script at the given position. + * + * @return Pair of (data, nextPosition), or null if the script is malformed + */ + private fun readPushData( + script: ByteArray, + pos: Int, + ): Pair? { + if (pos >= script.size) return null + + val opcode = script[pos].toInt() and 0xff + return when { + opcode == 0 -> { + // OP_0 / push empty + byteArrayOf() to (pos + 1) + } + + opcode < 0x4c -> { + // Direct push: opcode is the length + val end = pos + 1 + opcode + if (end > script.size) return null + script.copyOfRange(pos + 1, end) to end + } + + opcode == 0x4c -> { + // OP_PUSHDATA1: next byte is length + if (pos + 2 > script.size) return null + val len = script[pos + 1].toInt() and 0xff + val end = pos + 2 + len + if (end > script.size) return null + script.copyOfRange(pos + 2, end) to end + } + + opcode == 0x4d -> { + // OP_PUSHDATA2: next 2 bytes are length (little-endian) + if (pos + 3 > script.size) return null + val len = + (script[pos + 1].toInt() and 0xff) or + ((script[pos + 2].toInt() and 0xff) shl 8) + val end = pos + 3 + len + if (end > script.size) return null + script.copyOfRange(pos + 3, end) to end + } + + else -> { + null + } + } + } + + private fun hexToBytes(hex: String): ByteArray { + val len = hex.length + val data = ByteArray(len / 2) + for (i in 0 until len step 2) { + data[i / 2] = + ( + (Character.digit(hex[i], 16) shl 4) + + Character.digit(hex[i + 1], 16) + ).toByte() + } + return data + } + + private fun createSocket(server: ElectrumxServer): Socket = + if (server.useSsl) { + val factory = + if (server.trustAllCerts) { + trustAllSslFactory() + } else { + SSLSocketFactory.getDefault() as SSLSocketFactory + } + factory.createSocket().apply { + connect(InetSocketAddress(server.host, server.port), connectTimeoutMs.toInt()) + } + } else { + Socket().apply { + connect(InetSocketAddress(server.host, server.port), connectTimeoutMs.toInt()) + } + } + + /** + * Create an SSLSocketFactory that accepts any certificate. + * Used for servers with self-signed certificates. + */ + private fun trustAllSslFactory(): SSLSocketFactory { + val trustAllCerts = + arrayOf( + object : X509TrustManager { + override fun checkClientTrusted( + chain: Array, + authType: String, + ) {} + + override fun checkServerTrusted( + chain: Array, + authType: String, + ) {} + + override fun getAcceptedIssuers(): Array = arrayOf() + }, + ) + val sslContext = SSLContext.getInstance("TLS") + sslContext.init(null, trustAllCerts, java.security.SecureRandom()) + return sslContext.socketFactory + } + + private fun buildRpcRequest( + method: String, + params: List, + ): String { + val id = requestId.incrementAndGet() + val obj = + buildJsonObject { + put("jsonrpc", "2.0") + put("id", id) + put("method", method) + put( + "params", + json.encodeToJsonElement( + kotlinx.serialization.builtins.ListSerializer( + kotlinx.serialization.json.JsonElement + .serializer(), + ), + params.map { + when (it) { + is Boolean -> JsonPrimitive(it) + is Number -> JsonPrimitive(it) + else -> JsonPrimitive(it.toString()) + } + }, + ), + ) + } + return json.encodeToString(JsonObject.serializer(), obj) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinLookupCache.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinLookupCache.kt new file mode 100644 index 000000000..0c211e375 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinLookupCache.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip05.namecoin + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +data class CachedResult( + val result: NamecoinNostrResult?, + val timestamp: Long = System.currentTimeMillis(), +) + +class NamecoinLookupCache( + private val maxEntries: Int = 500, + private val ttlMs: Long = 3_600_000L, // 1 hour +) { + private val cache = LinkedHashMap(maxEntries, 0.75f, true) + private val mutex = Mutex() + + /** + * Normalised cache key from the user's raw input. + */ + private fun cacheKey(identifier: String): String = identifier.trim().lowercase() + + suspend fun get(identifier: String): CachedResult? = + mutex.withLock { + val key = cacheKey(identifier) + val entry = cache[key] ?: return null + val age = System.currentTimeMillis() - entry.timestamp + if (age > ttlMs) { + cache.remove(key) + return null + } + return entry + } + + suspend fun put( + identifier: String, + result: NamecoinNostrResult?, + ) = mutex.withLock { + val key = cacheKey(identifier) + if (cache.size >= maxEntries) { + // Remove eldest (LRU) entry + val eldest = cache.entries.firstOrNull() + if (eldest != null) cache.remove(eldest.key) + } + cache[key] = CachedResult(result) + } + + suspend fun invalidate(identifier: String) = + mutex.withLock { + cache.remove(cacheKey(identifier)) + } + + suspend fun clear() = + mutex.withLock { + cache.clear() + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolver.kt new file mode 100644 index 000000000..7f1edbf92 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolver.kt @@ -0,0 +1,313 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip05.namecoin + +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject + +/** + * Result of resolving a Namecoin name to Nostr identity data. + */ +data class NamecoinNostrResult( + /** Hex-encoded 32-byte Schnorr public key */ + val pubkey: String, + /** Optional relay URLs where this user can be found */ + val relays: List = emptyList(), + /** The Namecoin name that was resolved (e.g. "d/example") */ + val namecoinName: String, + /** The local-part that was matched (e.g. "alice" or "_") */ + val localPart: String = "_", +) + +/** + * Resolves Namecoin names to Nostr public keys. + * + * This is the primary entry point for Namecoin→Nostr resolution. + * It is designed to be used alongside Amethyst's existing NIP-05 + * verifier: if an identifier ends with `.bit`, it should be routed + * here instead of to the HTTP-based NIP-05 path. + */ +class NamecoinNameResolver( + private val electrumxClient: ElectrumxClient = ElectrumxClient(), + private val lookupTimeoutMs: Long = 20_000L, +) { + private val json = + Json { + ignoreUnknownKeys = true + isLenient = true + } + + companion object { + private val HEX_PUBKEY_REGEX = Regex("^[0-9a-fA-F]{64}$") + + /** + * Check whether an identifier should be routed to Namecoin + * resolution rather than standard NIP-05. + */ + fun isNamecoinIdentifier(identifier: String): Boolean { + val normalized = identifier.trim().lowercase() + return normalized.endsWith(".bit") || + normalized.startsWith("d/") || + normalized.startsWith("id/") + } + } + + /** + * Resolve a user-supplied identifier to a Nostr pubkey via Namecoin. + * + * @param identifier User input, e.g. "alice@example.bit", "id/alice", "example.bit" + * @return [NamecoinNostrResult] on success, null if resolution failed + */ + suspend fun resolve(identifier: String): NamecoinNostrResult? { + val parsed = parseIdentifier(identifier) ?: return null + return withTimeoutOrNull(lookupTimeoutMs) { + performLookup(parsed) + } + } + + // ── Identifier Parsing ───────────────────────────────────────────── + + /** + * Parsed representation of a Namecoin lookup request. + */ + private data class ParsedIdentifier( + /** The Namecoin name to query, e.g. "d/example" or "id/alice" */ + val namecoinName: String, + /** The local-part to look up within the name's value. + * For d/ names: the user part (or "_" for root). + * For id/ names: always "_". */ + val localPart: String, + /** Which namespace: DOMAIN or IDENTITY */ + val namespace: Namespace, + ) + + private enum class Namespace { DOMAIN, IDENTITY } + + /** + * Parse a user-supplied string into a structured lookup request. + * + * Accepted formats: + * "alice@example.bit" → d/example, localPart=alice + * "_@example.bit" → d/example, localPart=_ + * "example.bit" → d/example, localPart=_ + * "d/example" → d/example, localPart=_ + * "id/alice" → id/alice, localPart=_ + */ + private fun parseIdentifier(raw: String): ParsedIdentifier? { + val input = raw.trim() + + // Direct namespace references + if (input.startsWith("d/", ignoreCase = true)) { + return ParsedIdentifier( + namecoinName = input.lowercase(), + localPart = "_", + namespace = Namespace.DOMAIN, + ) + } + if (input.startsWith("id/", ignoreCase = true)) { + return ParsedIdentifier( + namecoinName = input.lowercase(), + localPart = "_", + namespace = Namespace.IDENTITY, + ) + } + + // NIP-05 style: user@domain.bit + if (input.contains("@") && input.endsWith(".bit", ignoreCase = true)) { + val parts = input.split("@", limit = 2) + if (parts.size != 2) return null + val localPart = parts[0].lowercase().ifEmpty { "_" } + val domain = parts[1].removeSuffix(".bit").lowercase() + if (domain.isEmpty()) return null + return ParsedIdentifier( + namecoinName = "d/$domain", + localPart = localPart, + namespace = Namespace.DOMAIN, + ) + } + + // Bare domain: example.bit + if (input.endsWith(".bit", ignoreCase = true)) { + val domain = input.removeSuffix(".bit").lowercase() + if (domain.isEmpty()) return null + return ParsedIdentifier( + namecoinName = "d/$domain", + localPart = "_", + namespace = Namespace.DOMAIN, + ) + } + + return null + } + + // ── Lookup & Value Parsing ───────────────────────────────────────── + + private suspend fun performLookup(parsed: ParsedIdentifier): NamecoinNostrResult? { + val nameResult = electrumxClient.nameShowWithFallback(parsed.namecoinName) ?: return null + val valueJson = tryParseJson(nameResult.value) ?: return null + + return when (parsed.namespace) { + Namespace.DOMAIN -> extractFromDomainValue(valueJson, parsed) + Namespace.IDENTITY -> extractFromIdentityValue(valueJson, parsed) + } + } + + /** + * Extract Nostr data from a `d/` domain value. + * + * Supports: + * { "nostr": "hex-pubkey" } → simple form + * { "nostr": { "names": { "alice": "hex" }, ... } } → extended NIP-05-like form + */ + private fun extractFromDomainValue( + value: JsonObject, + parsed: ParsedIdentifier, + ): NamecoinNostrResult? { + val nostrField = value["nostr"] ?: return null + + // Simple form: "nostr": "hex-pubkey" + if (nostrField is JsonPrimitive && nostrField.isString) { + val pubkey = nostrField.content + if (parsed.localPart == "_" && isValidPubkey(pubkey)) { + return NamecoinNostrResult( + pubkey = pubkey.lowercase(), + namecoinName = parsed.namecoinName, + localPart = "_", + ) + } + // Simple form only supports root — if a non-root local-part + // was requested, we can't resolve it. + if (parsed.localPart != "_") return null + } + + // Extended form: "nostr": { "names": {...}, "relays": {...} } + if (nostrField is JsonObject) { + val names = nostrField["names"]?.jsonObject ?: return null + val pubkeyElem = names[parsed.localPart] ?: names["_"] // fall back to root + val pubkey = (pubkeyElem as? JsonPrimitive)?.content ?: return null + if (!isValidPubkey(pubkey)) return null + + val relays = extractRelays(nostrField, pubkey) + return NamecoinNostrResult( + pubkey = pubkey.lowercase(), + relays = relays, + namecoinName = parsed.namecoinName, + localPart = parsed.localPart, + ) + } + + return null + } + + /** + * Extract Nostr data from an `id/` identity value. + * + * The id/ namespace stores general identity data. We look for: + * { "nostr": "hex-pubkey" } + * { "nostr": { "pubkey": "hex", "relays": [...] } } + */ + private fun extractFromIdentityValue( + value: JsonObject, + parsed: ParsedIdentifier, + ): NamecoinNostrResult? { + val nostrField = value["nostr"] ?: return null + + // Simple: "nostr": "hex-pubkey" + if (nostrField is JsonPrimitive && nostrField.isString) { + val pubkey = nostrField.content + if (isValidPubkey(pubkey)) { + return NamecoinNostrResult( + pubkey = pubkey.lowercase(), + namecoinName = parsed.namecoinName, + ) + } + } + + // Object form: "nostr": { "pubkey": "hex", "relays": [...] } + if (nostrField is JsonObject) { + // Try "pubkey" field + val pubkey = (nostrField["pubkey"] as? JsonPrimitive)?.content + if (pubkey != null && isValidPubkey(pubkey)) { + val relays = + try { + nostrField["relays"]?.jsonArray?.mapNotNull { + (it as? JsonPrimitive)?.content + } ?: emptyList() + } catch (_: Exception) { + emptyList() + } + + return NamecoinNostrResult( + pubkey = pubkey.lowercase(), + relays = relays, + namecoinName = parsed.namecoinName, + ) + } + + // Also try NIP-05-like "names" structure for id/ names + val names = nostrField["names"]?.jsonObject + if (names != null) { + val rootPubkey = (names["_"] as? JsonPrimitive)?.content + if (rootPubkey != null && isValidPubkey(rootPubkey)) { + val relays = extractRelays(nostrField, rootPubkey) + return NamecoinNostrResult( + pubkey = rootPubkey.lowercase(), + relays = relays, + namecoinName = parsed.namecoinName, + ) + } + } + } + + return null + } + + // ── Helpers ───────────────────────────────────────────────────────── + + private fun extractRelays( + nostrObj: JsonObject, + pubkey: String, + ): List { + return try { + val relaysMap = nostrObj["relays"]?.jsonObject ?: return emptyList() + val relayArray = + relaysMap[pubkey.lowercase()]?.jsonArray + ?: relaysMap[pubkey]?.jsonArray + ?: return emptyList() + relayArray.mapNotNull { (it as? JsonPrimitive)?.content } + } catch (_: Exception) { + emptyList() + } + } + + private fun tryParseJson(raw: String): JsonObject? = + try { + json.parseToJsonElement(raw).jsonObject + } catch (_: Exception) { + null + } + + private fun isValidPubkey(s: String): Boolean = HEX_PUBKEY_REGEX.matches(s) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Client.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Client.kt index c84aeed30..438c3f154 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Client.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Client.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.nip05DnsIdentifiers import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip05.namecoin.NamecoinNameResolver import kotlinx.coroutines.CancellationException data class Nip05KeyInfo( @@ -30,6 +31,7 @@ data class Nip05KeyInfo( class Nip05Client( val fetcher: Nip05Fetcher, + val namecoinResolver: NamecoinNameResolver? = null, ) { val parser = Nip05Parser() @@ -37,6 +39,12 @@ class Nip05Client( nip05: Nip05Id, hexKey: HexKey, ): Boolean { + // Namecoin: route .bit domains to blockchain verification + if (namecoinResolver != null && NamecoinNameResolver.isNamecoinIdentifier(nip05.toValue())) { + val result = namecoinResolver.resolve(nip05.toValue()) + return result?.pubkey == hexKey + } + val json = fetchNip05Data(nip05) val key = @@ -54,7 +62,15 @@ class Nip05Client( } } - suspend fun get(nip05: Nip05Id) = parser.parseHexKeyAndRelays(nip05, fetchNip05Data(nip05)) + suspend fun get(nip05: Nip05Id): Nip05KeyInfo? { + // Namecoin: route .bit domains to blockchain resolution + if (namecoinResolver != null && NamecoinNameResolver.isNamecoinIdentifier(nip05.toValue())) { + val result = namecoinResolver.resolve(nip05.toValue()) ?: return null + return Nip05KeyInfo(result.pubkey, result.relays) + } + + return parser.parseHexKeyAndRelays(nip05, fetchNip05Data(nip05)) + } suspend fun load(nip05: Nip05Id) = parser.parse(fetchNip05Data(nip05)) diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolverTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolverTest.kt new file mode 100644 index 000000000..c9a9a5499 --- /dev/null +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolverTest.kt @@ -0,0 +1,281 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip05.namecoin + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class NamecoinNameResolverTest { + // ── isNamecoinIdentifier ─────────────────────────────────────────── + + @Test + fun `recognizes dot-bit domains`() { + assertTrue(NamecoinNameResolver.isNamecoinIdentifier("example.bit")) + assertTrue(NamecoinNameResolver.isNamecoinIdentifier("alice@example.bit")) + assertTrue(NamecoinNameResolver.isNamecoinIdentifier("_@example.bit")) + assertTrue(NamecoinNameResolver.isNamecoinIdentifier("EXAMPLE.BIT")) + } + + @Test + fun `recognizes d-slash names`() { + assertTrue(NamecoinNameResolver.isNamecoinIdentifier("d/example")) + assertTrue(NamecoinNameResolver.isNamecoinIdentifier("D/Example")) + } + + @Test + fun `recognizes id-slash names`() { + assertTrue(NamecoinNameResolver.isNamecoinIdentifier("id/alice")) + assertTrue(NamecoinNameResolver.isNamecoinIdentifier("ID/Alice")) + } + + @Test + fun `rejects non-namecoin identifiers`() { + assertFalse(NamecoinNameResolver.isNamecoinIdentifier("[email protected]")) + assertFalse(NamecoinNameResolver.isNamecoinIdentifier("npub1abc")) + assertFalse(NamecoinNameResolver.isNamecoinIdentifier("some random text")) + assertFalse(NamecoinNameResolver.isNamecoinIdentifier("")) + } + + // ── Value format: simple pubkey in d/ ────────────────────────────── + + @Test + fun `parses simple nostr field from domain value`() { + val value = """{"nostr":"b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9"}""" + val result = extractNostrFromValue(value, "d/example", "_") + assertNotNull(result) + assertEquals("b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9", result!!.pubkey) + } + + // ── Value format: extended NIP-05-like in d/ ─────────────────────── + + @Test + fun `parses extended nostr names from domain value`() { + val value = """{ + "nostr": { + "names": { + "_": "aaaa000000000000000000000000000000000000000000000000000000000001", + "alice": "bbbb000000000000000000000000000000000000000000000000000000000002" + }, + "relays": { + "bbbb000000000000000000000000000000000000000000000000000000000002": [ + "wss://relay.example.com" + ] + } + } + }""" + + // Root lookup + val rootResult = extractNostrFromValue(value, "d/example", "_") + assertNotNull(rootResult) + assertEquals("aaaa000000000000000000000000000000000000000000000000000000000001", rootResult!!.pubkey) + + // Named lookup + val aliceResult = extractNostrFromValue(value, "d/example", "alice") + assertNotNull(aliceResult) + assertEquals("bbbb000000000000000000000000000000000000000000000000000000000002", aliceResult!!.pubkey) + assertEquals(listOf("wss://relay.example.com"), aliceResult.relays) + } + + @Test + fun `falls back to root when named user not found`() { + val value = """{ + "nostr": { + "names": { + "_": "aaaa000000000000000000000000000000000000000000000000000000000001" + } + } + }""" + + val result = extractNostrFromValue(value, "d/example", "nonexistent") + assertNotNull(result) + assertEquals("aaaa000000000000000000000000000000000000000000000000000000000001", result!!.pubkey) + } + + // ── Value format: id/ namespace ──────────────────────────────────── + + @Test + fun `parses simple nostr field from identity value`() { + val value = """{ + "nostr": "cccc000000000000000000000000000000000000000000000000000000000003", + "email": "[email protected]" + }""" + val result = extractNostrFromIdentityValue(value, "id/alice") + assertNotNull(result) + assertEquals("cccc000000000000000000000000000000000000000000000000000000000003", result!!.pubkey) + } + + @Test + fun `parses object nostr field from identity value`() { + val value = """{ + "nostr": { + "pubkey": "dddd000000000000000000000000000000000000000000000000000000000004", + "relays": ["wss://relay.example.com", "wss://relay2.example.com"] + } + }""" + val result = extractNostrFromIdentityValue(value, "id/bob") + assertNotNull(result) + assertEquals("dddd000000000000000000000000000000000000000000000000000000000004", result!!.pubkey) + assertEquals(2, result.relays.size) + } + + // ── Invalid data ─────────────────────────────────────────────────── + + @Test + fun `rejects invalid pubkey lengths`() { + val value = """{"nostr":"tooshort"}""" + val result = extractNostrFromValue(value, "d/bad", "_") + assertNull(result) + } + + @Test + fun `rejects non-hex pubkeys`() { + val value = """{"nostr":"zzzz000000000000000000000000000000000000000000000000000000000000"}""" + val result = extractNostrFromValue(value, "d/bad", "_") + assertNull(result) + } + + @Test + fun `handles missing nostr field`() { + val value = """{"ip":"1.2.3.4","map":{"www":{"ip":"1.2.3.4"}}}""" + val result = extractNostrFromValue(value, "d/example", "_") + assertNull(result) + } + + @Test + fun `handles malformed JSON gracefully`() { + val value = "not json at all" + val result = extractNostrFromValue(value, "d/broken", "_") + assertNull(result) + } + + // ── Test helpers ─────────────────────────────────────────────────── + + /** + * Directly test value parsing without network access. + * Simulates what NamecoinNameResolver does after receiving a name_show result. + */ + private fun extractNostrFromValue( + jsonValue: String, + namecoinName: String, + localPart: String, + ): NamecoinNostrResult? { + val json = + kotlinx.serialization.json.Json { + ignoreUnknownKeys = true + isLenient = true + } + val obj = + try { + json.parseToJsonElement(jsonValue).jsonObject + } catch (_: Exception) { + return null + } + + val nostrField = obj["nostr"] ?: return null + + // Simple form + if (nostrField is kotlinx.serialization.json.JsonPrimitive && nostrField.isString) { + val pubkey = nostrField.content + if (localPart == "_" && pubkey.matches(Regex("^[0-9a-fA-F]{64}$"))) { + return NamecoinNostrResult(pubkey = pubkey.lowercase(), namecoinName = namecoinName) + } + return null + } + + // Extended form + if (nostrField is kotlinx.serialization.json.JsonObject) { + val names = nostrField["names"]?.jsonObject ?: return null + val pubkeyElem = names[localPart] ?: names["_"] ?: return null + val pubkey = (pubkeyElem as? kotlinx.serialization.json.JsonPrimitive)?.content ?: return null + if (!pubkey.matches(Regex("^[0-9a-fA-F]{64}$"))) return null + + val relays = + try { + val relaysMap = nostrField["relays"]?.jsonObject + relaysMap?.get(pubkey.lowercase())?.jsonArray?.mapNotNull { + (it as? kotlinx.serialization.json.JsonPrimitive)?.content + } ?: emptyList() + } catch (_: Exception) { + emptyList() + } + + return NamecoinNostrResult( + pubkey = pubkey.lowercase(), + relays = relays, + namecoinName = namecoinName, + localPart = localPart, + ) + } + return null + } + + private fun extractNostrFromIdentityValue( + jsonValue: String, + namecoinName: String, + ): NamecoinNostrResult? { + val json = + kotlinx.serialization.json.Json { + ignoreUnknownKeys = true + isLenient = true + } + val obj = + try { + json.parseToJsonElement(jsonValue).jsonObject + } catch (_: Exception) { + return null + } + + val nostrField = obj["nostr"] ?: return null + + if (nostrField is kotlinx.serialization.json.JsonPrimitive && nostrField.isString) { + val pubkey = nostrField.content + if (pubkey.matches(Regex("^[0-9a-fA-F]{64}$"))) { + return NamecoinNostrResult(pubkey = pubkey.lowercase(), namecoinName = namecoinName) + } + } + + if (nostrField is kotlinx.serialization.json.JsonObject) { + val pubkey = (nostrField["pubkey"] as? kotlinx.serialization.json.JsonPrimitive)?.content + if (pubkey != null && pubkey.matches(Regex("^[0-9a-fA-F]{64}$"))) { + val relays = + try { + nostrField["relays"]?.jsonArray?.mapNotNull { + (it as? kotlinx.serialization.json.JsonPrimitive)?.content + } ?: emptyList() + } catch (_: Exception) { + emptyList() + } + return NamecoinNostrResult(pubkey = pubkey.lowercase(), relays = relays, namecoinName = namecoinName) + } + } + return null + } + + // Needed for jsonArray and jsonObject extensions + private val kotlinx.serialization.json.JsonElement.jsonObject + get() = this as kotlinx.serialization.json.JsonObject + private val kotlinx.serialization.json.JsonElement.jsonArray + get() = this as kotlinx.serialization.json.JsonArray +} From f6447d202018bae3b5451c0c1deb0d4d0affae5e Mon Sep 17 00:00:00 2001 From: M Date: Mon, 2 Mar 2026 19:43:24 +1100 Subject: [PATCH 03/13] feat: wire Namecoin search into SearchBarViewModel Resolve .bit, d/, and id/ identifiers from the search bar via ElectrumX blockchain lookups. Typing any Namecoin identifier format (m@testls.bit, testls.bit, d/testls, id/alice) now queries the Namecoin blockchain and shows the resolved user at the top of results. The namecoinResolvedUser flow in SearchBarViewModel detects Namecoin identifiers, resolves them through NamecoinNameService, and prepends the result to the standard local cache search results. Updated docs with search integration details and manual testing guide. --- .../loggedIn/search/SearchBarViewModel.kt | 38 +++++++++++++- docs/namecoin-nip05-design.md | 50 ++++++++++++++++--- 2 files changed, 80 insertions(+), 8 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt index 8004ccdde..e712185e2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt @@ -32,8 +32,11 @@ import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.namecoin.NamecoinNameService import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchQueryState import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.quartz.nip05.namecoin.NamecoinNameResolver import com.vitorpamplona.quartz.nip10Notes.content.findHashtags import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview @@ -43,7 +46,9 @@ import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update @@ -69,12 +74,41 @@ class SearchBarViewModel( val searchDataSourceState = SearchQueryState(MutableStateFlow(searchValue), account) + /** + * Resolves Namecoin identifiers (.bit / d/ / id/) via ElectrumX and + * returns the matching [User] from LocalCache, or null. + */ + private val namecoinResolvedUser = + searchValueFlow + .debounce(400) + .distinctUntilChanged() + .filter { NamecoinNameResolver.isNamecoinIdentifier(it) } + .map { term -> + try { + val result = NamecoinNameService.getInstance().resolve(term) + if (result != null) { + LocalCache.getOrCreateUser(result.pubkey) + } else { + null + } + } catch (_: Exception) { + null + } + }.flowOn(Dispatchers.IO) + .stateIn(viewModelScope, WhileSubscribed(5000), null) + val searchResultsUsers = combine( searchValueFlow.debounce(100), invalidations.debounce(100), - ) { term, version -> - LocalCache.findUsersStartingWith(term, account) + namecoinResolvedUser, + ) { term, version, namecoinUser -> + val localResults = LocalCache.findUsersStartingWith(term, account) + if (namecoinUser != null && localResults.none { it.pubkeyHex == namecoinUser.pubkeyHex }) { + listOf(namecoinUser) + localResults + } else { + localResults + } }.flowOn(Dispatchers.IO) .stateIn(viewModelScope, WhileSubscribed(5000), emptyList()) diff --git a/docs/namecoin-nip05-design.md b/docs/namecoin-nip05-design.md index 468c9f5f1..8289ac9d2 100644 --- a/docs/namecoin-nip05-design.md +++ b/docs/namecoin-nip05-design.md @@ -154,6 +154,17 @@ The integration is minimal and non-invasive: 3. Non-Namecoin identifiers are completely unaffected 4. **`AppModules`** wires up the resolver at construction time +## Search Integration + +The search bar resolves Namecoin identifiers in real-time via `SearchBarViewModel`: + +1. A `namecoinResolvedUser` flow watches the search input with a 400ms debounce +2. If the input matches any Namecoin format (`d/*`, `id/*`, `*.bit`, `*@*.bit`), it resolves via `NamecoinNameService` → `ElectrumxClient` → blockchain +3. The resolved pubkey is used to get/create a `User` in `LocalCache` +4. The Namecoin-resolved user is prepended to the standard local search results (deduplicated) + +This means typing `alice@example.bit`, `example.bit`, `d/example`, or `id/alice` into the search bar will query the Namecoin blockchain and show the resolved user profile at the top of results. + ## Default ElectrumX Server ``` @@ -202,6 +213,7 @@ Search bar integration. When a user types a `.bit` identifier, shows a loading s ### Modified files - `amethyst/.../AppModules.kt` — Wire up `NamecoinNameResolver` into `Nip05Client` +- `amethyst/.../ui/screen/loggedIn/search/SearchBarViewModel.kt` — Namecoin search resolution - `quartz/.../nip05DnsIdentifiers/Nip05Client.kt` — Route `.bit` identifiers to Namecoin resolver - `amethyst/.../relays/RelayInformationScreen.kt` — Import reordering (spotless) @@ -212,13 +224,34 @@ Search bar integration. When a user types a `.bit` identifier, shows a loading s ./gradlew :quartz:jvmTest --tests "*NamecoinNameResolverTest*" ``` -### Manual testing (emulator) -1. Build and install: `./gradlew :amethyst:installFdroidDebug` -2. Search for `m@testls.bit` — should resolve to pubkey `6cdebcca...18667d` -3. Search for `testls.bit` — root domain lookup -4. Search for `d/testls` — direct namespace format +### Manual testing (emulator or device) -### Live verification +Build and install the debug APK: +```bash +./gradlew assemblePlayDebug +adb install -r amethyst/build/outputs/apk/play/debug/amethyst-play-universal-debug.apk +``` + +**Search bar tests** — open the search bar and enter each of these: + +| Search query | Expected result | What it tests | +|---|---|---| +| `m@testls.bit` | Resolves to Vitor Pamplona's profile | NIP-05 style `user@domain.bit` | +| `testls.bit` | Resolves to Vitor Pamplona's profile (root `_` entry) | Bare domain `.bit` lookup | +| `d/testls` | Resolves to Vitor Pamplona's profile | Direct `d/` namespace | +| `id/someuser` | Resolves if registered on-chain | Direct `id/` namespace | + +**Verification test** — if a profile has a `.bit` address in its `nip05` field, the NIP-05 badge should verify via the blockchain instead of HTTP. + +**Network verification** — to confirm ElectrumX calls are being made: +```bash +# Monitor traffic to ElectrumX ports on the emulator +adb root +adb shell tcpdump -i any -nn port 50002 or port 50006 +``` +You should see TCP connections to `162.212.154.52:50002` (electrumx.testls.space) when searching for `.bit` identifiers. + +### Live test data The name `d/testls` is registered on the Namecoin blockchain (block 551519+, last updated block 814278) with value: ```json { @@ -229,3 +262,8 @@ The name `d/testls` is registered on the Namecoin blockchain (block 551519+, las } } ``` + +This means: +- `m@testls.bit` → resolves `m` entry → pubkey `6cdebcca...18667d` +- `testls.bit` → resolves root `_` entry → falls back to first available entry +- `d/testls` → same as `testls.bit` (root lookup) From 3f39f96e81eaeb28b0a5038b1a851cb13d9b17d3 Mon Sep 17 00:00:00 2001 From: M Date: Tue, 3 Mar 2026 06:28:01 +1100 Subject: [PATCH 04/13] fix: route ElectrumX through Tor proxy, add onion server - ElectrumxClient accepts injected SocketFactory (lambda) so connections respect the user's Tor/proxy settings instead of leaking their IP through raw sockets - Add ProxiedSocketFactory for SOCKS5 proxy routing - Add .onion ElectrumX server as primary when Tor is enabled, with electrumx.testls.space as clearnet fallback - NamecoinNameResolver accepts serverListProvider lambda for dynamic server selection based on current Tor settings - RoleBasedHttpClientBuilder.socketFactoryForNip05() bridges Amethyst's Tor settings to the socket factory - NamecoinNameService now requires explicit init with proxy-aware client - Remove dead code: Nip05NamecoinAdapter (never referenced), NamecoinVerificationDisplay (never called) - Update design documentation --- .../com/vitorpamplona/amethyst/AppModules.kt | 16 +- .../privacyOptions/ProxiedSocketFactory.kt | 70 +++++ .../RoleBasedHttpClientBuilder.kt | 23 ++ .../service/namecoin/NamecoinNameService.kt | 15 +- .../service/namecoin/Nip05NamecoinAdapter.kt | 62 ----- .../namecoin/NamecoinVerificationDisplay.kt | 259 ------------------ docs/namecoin-nip05-design.md | 122 ++++++--- .../quartz/nip05/namecoin/ElectrumxClient.kt | 57 ++-- .../nip05/namecoin/NamecoinNameResolver.kt | 3 +- 9 files changed, 241 insertions(+), 386 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/ProxiedSocketFactory.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/Nip05NamecoinAdapter.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/namecoin/NamecoinVerificationDisplay.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 25ef825e6..41e45652b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -142,11 +142,23 @@ class AppModules( // Custom fetcher that considers tor settings and avoids forwarding. val nip05Fetcher = OkHttpNip05Fetcher(roleBasedHttpClientBuilder::okHttpClientForNip05) + val namecoinElectrumxClient = + com.vitorpamplona.quartz.nip05.namecoin.ElectrumxClient( + socketFactory = { roleBasedHttpClientBuilder.socketFactoryForNip05() }, + ) val namecoinResolver = com.vitorpamplona.quartz.nip05.namecoin.NamecoinNameResolver( - com.vitorpamplona.quartz.nip05.namecoin - .ElectrumxClient(), + electrumxClient = namecoinElectrumxClient, + serverListProvider = { + if (roleBasedHttpClientBuilder.shouldUseTorForNIP05("https://electrumx.example.com")) { + com.vitorpamplona.quartz.nip05.namecoin.ElectrumxClient.TOR_SERVERS + } else { + com.vitorpamplona.quartz.nip05.namecoin.ElectrumxClient.DEFAULT_SERVERS + } + }, ) + val namecoinNameService = + com.vitorpamplona.amethyst.service.namecoin.NamecoinNameService.init(namecoinElectrumxClient) val nip05Client = Nip05Client(nip05Fetcher, namecoinResolver) // Application-wide block height request cache diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/ProxiedSocketFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/ProxiedSocketFactory.kt new file mode 100644 index 000000000..53a52361f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/ProxiedSocketFactory.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.privacyOptions + +import java.net.InetAddress +import java.net.Proxy +import java.net.Socket +import javax.net.SocketFactory + +/** + * A [SocketFactory] that creates sockets routed through a SOCKS proxy. + * + * Used to ensure raw TCP connections (e.g. ElectrumX for Namecoin) + * respect the user's Tor/proxy settings, preventing IP leaks. + */ +class ProxiedSocketFactory( + private val proxy: Proxy, +) : SocketFactory() { + override fun createSocket(): Socket = Socket(proxy) + + override fun createSocket( + host: String, + port: Int, + ): Socket = Socket(proxy).apply { connect(java.net.InetSocketAddress(host, port)) } + + override fun createSocket( + host: String, + port: Int, + localHost: InetAddress, + localPort: Int, + ): Socket = + Socket(proxy).apply { + bind(java.net.InetSocketAddress(localHost, localPort)) + connect(java.net.InetSocketAddress(host, port)) + } + + override fun createSocket( + host: InetAddress, + port: Int, + ): Socket = Socket(proxy).apply { connect(java.net.InetSocketAddress(host, port)) } + + override fun createSocket( + address: InetAddress, + port: Int, + localAddress: InetAddress, + localPort: Int, + ): Socket = + Socket(proxy).apply { + bind(java.net.InetSocketAddress(localAddress, localPort)) + connect(java.net.InetSocketAddress(address, port)) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt index aacb3e262..daea244bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt @@ -25,6 +25,9 @@ import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow import com.vitorpamplona.amethyst.ui.tor.TorType import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import okhttp3.OkHttpClient +import java.net.InetSocketAddress +import java.net.Proxy +import javax.net.SocketFactory class RoleBasedHttpClientBuilder( val okHttpClient: DualHttpClientManager, @@ -141,4 +144,24 @@ class RoleBasedHttpClientBuilder( override fun okHttpClientForPreview(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForPreviewUrl(url)) override fun okHttpClientForPushRegistration(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForTrustedRelays()) + + /** + * Returns a [SocketFactory] that routes through the user's Tor proxy + * when NIP-05 verification traffic should use Tor. + * + * Used by [ElectrumxClient] so that Namecoin lookups respect the + * same proxy/Tor settings as HTTP-based NIP-05 verification, + * preventing IP leaks through direct socket connections. + */ + fun socketFactoryForNip05(): SocketFactory { + // ElectrumX servers are always external, so we use a dummy + // non-localhost, non-onion URL to query the Tor policy. + val useTor = shouldUseTorForNIP05("https://electrumx.example.com") + if (!useTor) return SocketFactory.getDefault() + + val proxy = okHttpClient.getCurrentProxy() ?: return SocketFactory.getDefault() + val proxyAddr = proxy.address() as? InetSocketAddress ?: return SocketFactory.getDefault() + + return ProxiedSocketFactory(Proxy(Proxy.Type.SOCKS, proxyAddr)) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt index 178a755b6..6f58bfcaa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt @@ -38,10 +38,11 @@ import kotlinx.coroutines.launch * Thread-safe, lifecycle-aware, and designed for integration * into Amethyst's existing `ServiceManager` infrastructure. */ -class NamecoinNameService private constructor() { +class NamecoinNameService( + electrumxClient: ElectrumxClient = ElectrumxClient(), +) { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private val electrumxClient = ElectrumxClient() private val resolver = NamecoinNameResolver(electrumxClient) private val cache = NamecoinLookupCache() @@ -53,8 +54,14 @@ class NamecoinNameService private constructor() { private var instance: NamecoinNameService? = null fun getInstance(): NamecoinNameService = - instance ?: synchronized(this) { - instance ?: NamecoinNameService().also { instance = it } + instance ?: throw IllegalStateException( + "NamecoinNameService not initialized. Call init() first.", + ) + + fun init(electrumxClient: ElectrumxClient): NamecoinNameService = + synchronized(this) { + instance?.let { return it } + NamecoinNameService(electrumxClient).also { instance = it } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/Nip05NamecoinAdapter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/Nip05NamecoinAdapter.kt deleted file mode 100644 index 706570c16..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/Nip05NamecoinAdapter.kt +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.service.namecoin - -import com.vitorpamplona.quartz.nip05.namecoin.NamecoinNameResolver - -/** - * Static adapter for hooking Namecoin into NIP-05 verification. - */ -object Nip05NamecoinAdapter { - /** - * Attempt Namecoin-based NIP-05 verification. - * - * @param nip05Address The `nip05` field from a kind-0 event - * @param expectedPubkeyHex The hex pubkey from the same event - * @return `true` if verified via Namecoin, `false` if Namecoin says - * the mapping is wrong, or `null` if this identifier is not - * a Namecoin identifier (caller should fall through to HTTP NIP-05). - */ - suspend fun tryVerify( - nip05Address: String, - expectedPubkeyHex: String, - ): Boolean? { - if (!NamecoinNameResolver.isNamecoinIdentifier(nip05Address)) { - return null // Not a Namecoin identifier → let caller handle via HTTP - } - return NamecoinNameService.getInstance().verifyNip05(nip05Address, expectedPubkeyHex) - } - - /** - * Attempt to resolve a Namecoin identifier from the search bar. - * - * Called from the search/discovery flow when the user types an - * identifier. Returns the hex pubkey if found, null otherwise. - * - * @param query The user's search input - * @return Pair of (pubkey, relayList) or null - */ - suspend fun tryResolveSearch(query: String): Pair>? { - if (!NamecoinNameResolver.isNamecoinIdentifier(query)) return null - val result = NamecoinNameService.getInstance().resolve(query) ?: return null - return Pair(result.pubkey, result.relays) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/namecoin/NamecoinVerificationDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/namecoin/NamecoinVerificationDisplay.kt deleted file mode 100644 index 2942b091f..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/namecoin/NamecoinVerificationDisplay.kt +++ /dev/null @@ -1,259 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.note.namecoin - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.vitorpamplona.amethyst.service.namecoin.NamecoinNameService -import com.vitorpamplona.amethyst.service.namecoin.NamecoinResolveState - -/** - * Display a Namecoin-verified identity badge. - * - * Shows the Namecoin name with a blockchain icon when verified. - * Renders nothing if verification fails or is still loading. - * - * @param nip05 The nip05 field value ending in .bit or starting with id/ - * @param expectedPubkeyHex The profile's pubkey to verify against - * @param modifier Standard compose modifier - */ -@Composable -fun NamecoinVerificationDisplay( - nip05: String, - expectedPubkeyHex: String, - modifier: Modifier = Modifier, -) { - var isVerified by remember(nip05, expectedPubkeyHex) { mutableStateOf(null) } - - LaunchedEffect(nip05, expectedPubkeyHex) { - isVerified = NamecoinNameService.getInstance().verifyNip05(nip05, expectedPubkeyHex) - } - - if (isVerified == true) { - NamecoinBadge( - displayName = formatDisplayName(nip05), - namespace = inferNamespace(nip05), - modifier = modifier, - ) - } -} - -/** - * The visual badge component. - * - * Shows: [chain icon] [display name] - * With namespace-appropriate styling. - */ -@Composable -private fun NamecoinBadge( - displayName: String, - namespace: String, // "d/" or "id/" - modifier: Modifier = Modifier, -) { - Row( - modifier = modifier.padding(top = 1.dp, bottom = 1.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start, - ) { - // Blockchain chain-link icon - // In production, replace with a proper vector drawable. - // For now, use a text glyph as placeholder. - Text( - text = "\u26D3", // ⛓ chain link emoji - fontSize = 12.sp, - color = NamecoinColors.chainIcon, - modifier = Modifier.padding(end = 2.dp), - ) - - // Namespace indicator - Text( - text = displayName, - fontSize = 14.sp, - color = NamecoinColors.verifiedText, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } -} - -/** - * Composable for the search result when a Namecoin name resolves. - * - * Shows the resolved Namecoin name, the Nostr pubkey, and relay hints. - * Used in the search results list. - */ -@Composable -fun NamecoinSearchResult( - identifier: String, - onProfileClick: (pubkeyHex: String) -> Unit, - modifier: Modifier = Modifier, -) { - val resolveState by NamecoinNameService - .getInstance() - .resolveLive(identifier) - .collectAsState() - - when (val state = resolveState) { - is NamecoinResolveState.Loading -> { - Row( - modifier = modifier.padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "\u26D3", // ⛓ - fontSize = 16.sp, - color = NamecoinColors.chainIcon, - ) - Spacer(Modifier.width(8.dp)) - Text( - text = "Resolving $identifier via Namecoin…", - fontSize = 14.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - is NamecoinResolveState.Resolved -> { - Row( - modifier = - modifier - .clickable { onProfileClick(state.result.pubkey) } - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "\u26D3", // ⛓ - fontSize = 16.sp, - color = NamecoinColors.verified, - ) - Spacer(Modifier.width(8.dp)) - // The resolved identity - Text( - text = formatDisplayName(identifier), - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, - ) - Spacer(Modifier.width(4.dp)) - Text( - text = "(${state.result.pubkey.take(8)}…)", - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - is NamecoinResolveState.NotFound -> { - Row( - modifier = modifier.padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "No Nostr identity found for $identifier", - fontSize = 14.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - is NamecoinResolveState.Error -> { - Row( - modifier = modifier.padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Namecoin lookup failed: ${state.message}", - fontSize = 14.sp, - color = MaterialTheme.colorScheme.error, - ) - } - } - } -} - -// ── Display Helpers ──────────────────────────────────────────────────── - -/** - * Format a raw identifier for user-friendly display. - * - * "alice@example.bit" → "alice@example.bit" - * "_@example.bit" → "example.bit" (NIP-05 root convention) - * "d/example" → "example.bit" - * "id/alice" → "id/alice" - */ -private fun formatDisplayName(identifier: String): String { - val input = identifier.trim() - - // _@domain.bit → show just domain.bit - if (input.startsWith("_@")) { - return input.removePrefix("_@") - } - - // d/name → name.bit - if (input.startsWith("d/", ignoreCase = true)) { - return input.removePrefix("d/").removePrefix("D/") + ".bit" - } - - // id/name stays as-is - if (input.startsWith("id/", ignoreCase = true)) { - return input - } - - return input -} - -private fun inferNamespace(identifier: String): String { - val input = identifier.trim().lowercase() - return when { - input.startsWith("id/") -> "id/" - else -> "d/" - } -} - -/** - * Color palette for Namecoin verification UI. - */ -private object NamecoinColors { - val chainIcon = Color(0xFF4A90D9) // Namecoin blue - val verified = Color(0xFF2E8B57) // Sea green — distinct from NIP-05 blue - val verifiedText = Color(0xFF4A90D9) // Namecoin blue for the text -} diff --git a/docs/namecoin-nip05-design.md b/docs/namecoin-nip05-design.md index 8289ac9d2..557e88ff9 100644 --- a/docs/namecoin-nip05-design.md +++ b/docs/namecoin-nip05-design.md @@ -17,43 +17,80 @@ This is censorship-resistant identity verification: no web server to seize, no D │ └── Namecoin path (new) ── NamecoinNameResolver │ │ │ │ │ NamecoinNameService (singleton) │ │ -│ ├── NamecoinLookupCache │ │ -│ └── Nip05NamecoinAdapter │ │ +│ └── NamecoinLookupCache │ │ │ │ │ -│ UI: NamecoinVerificationDisplay │ │ -│ NamecoinSearchResult │ │ +│ RoleBasedHttpClientBuilder │ │ +│ └── socketFactoryForNip05() ───┤ (Tor-aware sockets) │ +│ │ │ +│ ProxiedSocketFactory │ │ +│ └── SOCKS5 proxy routing ───┘ │ ├────────────────────────────────────┼────────────────────────┤ │ Quartz Library │ ├────────────────────────────────────┼────────────────────────┤ │ NamecoinNameResolver │ │ │ ├── parseIdentifier() │ │ │ ├── extractFromDomainValue() │ (d/ namespace) │ -│ └── extractFromIdentityValue() │ (id/ namespace) │ +│ ├── extractFromIdentityValue() │ (id/ namespace) │ +│ └── serverListProvider() │ (Tor/clearnet routing) │ │ │ │ │ ElectrumxClient │ │ │ ├── buildNameIndexScript() │ │ │ ├── electrumScriptHash() │ │ -│ └── parseNameScript() │ │ +│ ├── parseNameScript() │ │ +│ └── socketFactory() ───┤ (injected, proxy-aware)│ │ ▼ │ -│ ┌──────────────────┐ │ -│ │ ElectrumX Server │ │ -│ │ (Namecoin node) │ │ -│ └──────────────────┘ │ +│ ┌───────────────────────────┐ │ +│ │ ElectrumX Server │ │ +│ │ (clearnet or .onion) │ │ +│ └───────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ ``` ### Layer Separation - **`quartz/` (library)** — Protocol-level logic. No Android dependencies. - - `ElectrumxClient` — TCP/TLS connection to ElectrumX, JSON-RPC, script parsing - - `NamecoinNameResolver` — Identifier parsing, value extraction, NIP-05 mapping + - `ElectrumxClient` — TCP/TLS connection to ElectrumX, JSON-RPC, script parsing. Accepts an injected `SocketFactory` lambda for proxy/Tor support. + - `NamecoinNameResolver` — Identifier parsing, value extraction, NIP-05 mapping. Accepts a `serverListProvider` lambda for dynamic server selection. - `NamecoinLookupCache` — LRU cache with TTL - `NamecoinNameResolverTest` — Unit tests for parsing and value extraction -- **`amethyst/` (app)** — Android integration and UI. - - `NamecoinNameService` — Application singleton, lifecycle management - - `Nip05NamecoinAdapter` — Static bridge for NIP-05 verification hooks - - `NamecoinVerificationDisplay` — Compose UI for verified badge + search results +- **`amethyst/` (app)** — Android integration and Tor-aware wiring. + - `NamecoinNameService` — Application singleton, initialized with a proxy-aware `ElectrumxClient` + - `ProxiedSocketFactory` — `SocketFactory` implementation that routes through a SOCKS5 proxy (Tor) + - `RoleBasedHttpClientBuilder.socketFactoryForNip05()` — Returns a proxy-aware or default `SocketFactory` based on current Tor settings + +## Tor & Proxy Integration + +The ElectrumX connection respects the user's Tor settings to prevent IP leaks: + +### Problem + +The original `ElectrumxClient` used raw `java.net.Socket` / `SSLSocket` directly, bypassing OkHttp entirely. This meant Namecoin lookups would leak the user's real IP even when they had configured Tor for NIP-05 verification traffic. + +### Solution + +1. **`ElectrumxClient`** accepts a `socketFactory: () -> SocketFactory` lambda (evaluated at each connection, not captured at construction) +2. **`ProxiedSocketFactory`** creates sockets routed through a `java.net.Proxy` (SOCKS5) +3. **`RoleBasedHttpClientBuilder.socketFactoryForNip05()`** checks the user's NIP-05 Tor settings and returns either `SocketFactory.getDefault()` or a `ProxiedSocketFactory` with the active Tor SOCKS proxy +4. SSL is layered on top of the (possibly proxied) base socket via `SSLSocketFactory.createSocket(socket, host, port, autoClose)`, preserving the proxy tunnel + +### Server Selection + +When Tor is enabled for NIP-05 traffic, the server list switches to prioritize onion routing: + +| Setting | Primary server | Fallback | +|---|---|---| +| **Tor off** | `electrumx.testls.space:50002` | `ulrichard.ch:50006`, `nmc2.lelux.fi:50006` | +| **Tor on** | `.onion:50002` (see below) | `electrumx.testls.space:50002` (via Tor) | + +The `serverListProvider` lambda in `NamecoinNameResolver` is evaluated at resolution time, so toggling Tor settings takes effect immediately without restarting the app. + +### Dynamic Evaluation + +Both the socket factory and server list are provided as lambdas, not captured values. This means: +- Toggling Tor on/off in settings takes effect on the next Namecoin lookup +- The proxy port is read from `DualHttpClientManager`'s live `StateFlow` +- No app restart or singleton reconstruction needed ## ElectrumX Protocol — How Name Resolution Works @@ -152,7 +189,7 @@ The integration is minimal and non-invasive: 1. **`Nip05Client`** gains an optional `namecoinResolver` parameter 2. On `verify()` and `get()`, if the identifier matches `.bit` / `d/` / `id/`, it routes to `NamecoinNameResolver` instead of the HTTP fetcher 3. Non-Namecoin identifiers are completely unaffected -4. **`AppModules`** wires up the resolver at construction time +4. **`AppModules`** wires up the resolver with Tor-aware socket factory and dynamic server selection ## Search Integration @@ -165,15 +202,20 @@ The search bar resolves Namecoin identifiers in real-time via `SearchBarViewMode This means typing `alice@example.bit`, `example.bit`, `d/example`, or `id/alice` into the search bar will query the Namecoin blockchain and show the resolved user profile at the top of results. -## Default ElectrumX Server +## Default ElectrumX Servers -``` -electrumx.testls.space:50002 (TLS, self-signed certificate) -``` +### Clearnet (Tor off) +| Server | Port | TLS | Notes | +|---|---|---|---| +| `electrumx.testls.space` | 50002 | Yes (self-signed) | Primary | +| `ulrichard.ch` | 50006 | Yes | Fallback | +| `nmc2.lelux.fi` | 50006 | Yes | Fallback | -- ElectrumX 1.16.0, Namecoin chain, protocol 1.4–1.4.3 -- Also available via Tor: `i665jpwsq46zlsdbnj4axgzd3s56uzey5uhotsnxzsknzbn36jaddsid.onion:50002` -- Fallback servers: `ulrichard.ch:50006`, `nmc2.lelux.fi:50006` (currently offline) +### Tor (Tor on for NIP-05) +| Server | Port | TLS | Notes | +|---|---|---|---| +| `i665jpwsq46zlsdbnj4axgzd3s56uzey5uhotsnxzsknzbn36jaddsid.onion` | 50002 | Yes (self-signed) | Primary — onion service for `electrumx.testls.space` | +| `electrumx.testls.space` | 50002 | Yes (self-signed) | Fallback (routed through Tor SOCKS proxy) | The `trustAllCerts` flag is set for servers with self-signed certificates. Users can configure custom servers via `NamecoinNameService.setCustomServers()`. @@ -184,38 +226,31 @@ The `trustAllCerts` flag is set for servers with self-signed certificates. Users - Both positive and negative results are cached - Cache is invalidated on TTL expiry; manual `invalidate()` and `clear()` are available -## UI Components - -### NamecoinVerificationDisplay -Shows a ⛓ chain-link badge next to profiles verified via Namecoin. Distinct from the standard NIP-05 checkmark — uses Namecoin blue (#4A90D9) and sea green (#2E8B57). - -### NamecoinSearchResult -Search bar integration. When a user types a `.bit` identifier, shows a loading state during resolution, then the resolved pubkey with a clickable profile link. - ## Security Considerations +- **Tor integration**: ElectrumX connections are routed through the user's Tor SOCKS proxy when NIP-05 Tor settings are enabled. This prevents IP leaks to ElectrumX servers. The onion server is preferred when Tor is active, providing end-to-end onion routing. - **Self-signed certificates**: The primary ElectrumX server uses a self-signed TLS cert. The `trustAllCerts` option accepts any certificate for that server. This is acceptable because the Namecoin blockchain itself provides the trust anchor — we verify names against on-chain data, not the transport layer. A MITM could return stale data but cannot forge name registrations. - **Name expiry**: Namecoin names expire after ~36,000 blocks (~250 days) if not renewed. The current implementation does not check expiry. Future work should compare the name's `height` + `expiresIn` against the current block height. - **Server trust**: The client trusts that the ElectrumX server returns accurate transaction data. For higher assurance, SPV proof verification could be added in the future. +- **Dynamic proxy evaluation**: Socket factory and server list are evaluated per-request (via lambdas), ensuring Tor setting changes take effect immediately without stale socket reuse. ## Files Changed ### New files (quartz/) -- `quartz/.../nip05/namecoin/ElectrumxClient.kt` — ElectrumX TCP/TLS client, scripthash-based name resolution -- `quartz/.../nip05/namecoin/NamecoinNameResolver.kt` — Identifier parsing, value extraction +- `quartz/.../nip05/namecoin/ElectrumxClient.kt` — ElectrumX TCP/TLS client with injected `SocketFactory` for proxy support +- `quartz/.../nip05/namecoin/NamecoinNameResolver.kt` — Identifier parsing, value extraction, dynamic server selection - `quartz/.../nip05/namecoin/NamecoinLookupCache.kt` — LRU cache with TTL - `quartz/src/jvmTest/.../NamecoinNameResolverTest.kt` — Unit tests ### New files (amethyst/) -- `amethyst/.../service/namecoin/NamecoinNameService.kt` — App singleton, coroutine scope -- `amethyst/.../service/namecoin/Nip05NamecoinAdapter.kt` — Static bridge for NIP-05 hooks -- `amethyst/.../ui/note/namecoin/NamecoinVerificationDisplay.kt` — Compose UI components +- `amethyst/.../service/namecoin/NamecoinNameService.kt` — App singleton, initialized with proxy-aware ElectrumxClient +- `amethyst/.../model/privacyOptions/ProxiedSocketFactory.kt` — `SocketFactory` that routes through SOCKS5 proxy (Tor) ### Modified files -- `amethyst/.../AppModules.kt` — Wire up `NamecoinNameResolver` into `Nip05Client` +- `amethyst/.../AppModules.kt` — Wires up resolver with Tor-aware socket factory and server list provider +- `amethyst/.../model/privacyOptions/RoleBasedHttpClientBuilder.kt` — Added `socketFactoryForNip05()` for proxy-aware socket creation - `amethyst/.../ui/screen/loggedIn/search/SearchBarViewModel.kt` — Namecoin search resolution - `quartz/.../nip05DnsIdentifiers/Nip05Client.kt` — Route `.bit` identifiers to Namecoin resolver -- `amethyst/.../relays/RelayInformationScreen.kt` — Import reordering (spotless) ## Testing @@ -241,15 +276,20 @@ adb install -r amethyst/build/outputs/apk/play/debug/amethyst-play-universal-deb | `d/testls` | Resolves to Vitor Pamplona's profile | Direct `d/` namespace | | `id/someuser` | Resolves if registered on-chain | Direct `id/` namespace | +**Tor tests** — enable Tor and set "NIP-05 verifications via Tor" to on: +1. Search for `m@testls.bit` — should resolve via onion server +2. Verify no direct clearnet connections to ElectrumX servers (use `tcpdump`) +3. Toggle Tor off — next search should use clearnet servers + **Verification test** — if a profile has a `.bit` address in its `nip05` field, the NIP-05 badge should verify via the blockchain instead of HTTP. **Network verification** — to confirm ElectrumX calls are being made: ```bash -# Monitor traffic to ElectrumX ports on the emulator adb root adb shell tcpdump -i any -nn port 50002 or port 50006 ``` -You should see TCP connections to `162.212.154.52:50002` (electrumx.testls.space) when searching for `.bit` identifiers. +With Tor off, you should see TCP connections to `162.212.154.52:50002` (electrumx.testls.space). +With Tor on, you should see connections to the local Tor SOCKS port only (no direct ElectrumX connections). ### Live test data The name `d/testls` is registered on the Namecoin blockchain (block 551519+, last updated block 814278) with value: diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt index 346e7bb8b..4f61a1fb0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt @@ -42,6 +42,7 @@ import java.net.InetSocketAddress import java.net.Socket import java.security.MessageDigest import java.util.concurrent.atomic.AtomicInteger +import javax.net.SocketFactory import javax.net.ssl.SSLContext import javax.net.ssl.SSLSocketFactory import javax.net.ssl.TrustManager @@ -97,6 +98,7 @@ data class ElectrumxServer( class ElectrumxClient( private val connectTimeoutMs: Long = 10_000L, private val readTimeoutMs: Long = 15_000L, + private val socketFactory: () -> SocketFactory = { SocketFactory.getDefault() }, ) { private val json = Json { @@ -107,7 +109,7 @@ class ElectrumxClient( private val mutex = Mutex() companion object { - /** Well-known public Namecoin ElectrumX servers. */ + /** Well-known public Namecoin ElectrumX servers (clearnet). */ val DEFAULT_SERVERS = listOf( ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, trustAllCerts = true), @@ -115,6 +117,18 @@ class ElectrumxClient( ElectrumxServer("nmc2.lelux.fi", 50006, useSsl = true), ) + /** Tor-preferred server list: onion primary, clearnet fallback. */ + val TOR_SERVERS = + listOf( + ElectrumxServer( + "i665jpwsq46zlsdbnj4axgzd3s56uzey5uhotsnxzsknzbn36jaddsid.onion", + 50002, + useSsl = true, + trustAllCerts = true, + ), + ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, trustAllCerts = true), + ) + private const val PROTOCOL_VERSION = "1.4" // Namecoin script opcodes @@ -155,10 +169,16 @@ class ElectrumxClient( } /** - * Try each default server in order until one succeeds. + * Try each server in order until one succeeds. + * + * @param identifier Full Namecoin name, e.g. "d/example" + * @param servers Ordered server list to try; defaults to [DEFAULT_SERVERS] */ - suspend fun nameShowWithFallback(identifier: String): NameShowResult? { - for (server in DEFAULT_SERVERS) { + suspend fun nameShowWithFallback( + identifier: String, + servers: List = DEFAULT_SERVERS, + ): NameShowResult? { + for (server in servers) { val result = nameShow(identifier, server) if (result != null) return result } @@ -412,22 +432,25 @@ class ElectrumxClient( return data } - private fun createSocket(server: ElectrumxServer): Socket = - if (server.useSsl) { - val factory = - if (server.trustAllCerts) { - trustAllSslFactory() - } else { - SSLSocketFactory.getDefault() as SSLSocketFactory - } - factory.createSocket().apply { + private fun createSocket(server: ElectrumxServer): Socket { + // Create the base socket through the injected factory, which + // may route through a SOCKS proxy (e.g. Tor) if configured. + val baseSocket = + socketFactory().createSocket().apply { connect(InetSocketAddress(server.host, server.port), connectTimeoutMs.toInt()) } - } else { - Socket().apply { - connect(InetSocketAddress(server.host, server.port), connectTimeoutMs.toInt()) + + if (!server.useSsl) return baseSocket + + // Upgrade to TLS over the already-connected (possibly proxied) socket. + val sslFactory = + if (server.trustAllCerts) { + trustAllSslFactory() + } else { + SSLSocketFactory.getDefault() as SSLSocketFactory } - } + return sslFactory.createSocket(baseSocket, server.host, server.port, true) + } /** * Create an SSLSocketFactory that accepts any certificate. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolver.kt index 7f1edbf92..f0f7549c1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolver.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolver.kt @@ -52,6 +52,7 @@ data class NamecoinNostrResult( class NamecoinNameResolver( private val electrumxClient: ElectrumxClient = ElectrumxClient(), private val lookupTimeoutMs: Long = 20_000L, + private val serverListProvider: () -> List = { ElectrumxClient.DEFAULT_SERVERS }, ) { private val json = Json { @@ -165,7 +166,7 @@ class NamecoinNameResolver( // ── Lookup & Value Parsing ───────────────────────────────────────── private suspend fun performLookup(parsed: ParsedIdentifier): NamecoinNostrResult? { - val nameResult = electrumxClient.nameShowWithFallback(parsed.namecoinName) ?: return null + val nameResult = electrumxClient.nameShowWithFallback(parsed.namecoinName, serverListProvider()) ?: return null val valueJson = tryParseJson(nameResult.value) ?: return null return when (parsed.namespace) { From 69e95159e9e71e282b93d23580b9eb2752bc16ce Mon Sep 17 00:00:00 2001 From: M Date: Tue, 3 Mar 2026 06:56:59 +1100 Subject: [PATCH 05/13] fix: check Namecoin name expiry before resolving Query current block height via blockchain.headers.subscribe and reject names that have expired (>= 36000 blocks since last update). Populate expiresIn field in NameShowResult for downstream use. Unconfirmed transactions (height <= 0) are treated as active. --- .../quartz/nip05/namecoin/ElectrumxClient.kt | 47 ++++++++++++++++++- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt index 4f61a1fb0..2e3cf2e1a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt @@ -131,6 +131,13 @@ class ElectrumxClient( private const val PROTOCOL_VERSION = "1.4" + /** + * Namecoin names expire this many blocks after their last update. + * From chainparams.cpp: consensus.nNameExpirationDepth = 36000 + * (~250 days at ~10 min/block). + */ + const val NAME_EXPIRE_DEPTH = 36_000 + // Namecoin script opcodes private const val OP_NAME_UPDATE: Byte = 0x53 // OP_3 repurposed by Namecoin private const val OP_2DROP: Byte = 0x6d @@ -222,8 +229,28 @@ class ElectrumxClient( writer.println(txReq) val txResponse = reader.readLine() ?: return null - // 5. Parse the name value from the transaction - return parseNameFromTransaction(identifier, txHash, height, txResponse) + // 5. Get current block height to check name expiry + val headersReq = buildRpcRequest("blockchain.headers.subscribe", emptyList()) + writer.println(headersReq) + val headersResponse = reader.readLine() + val currentHeight = parseBlockHeight(headersResponse) + + // 6. Check if the name has expired + if (currentHeight != null && height > 0) { + val blocksSinceUpdate = currentHeight - height + if (blocksSinceUpdate >= NAME_EXPIRE_DEPTH) { + return null // Name has expired + } + } + + // 7. Parse the name value from the transaction + val result = parseNameFromTransaction(identifier, txHash, height, txResponse) + // Populate expiresIn if we know the current height + return if (result != null && currentHeight != null && height > 0) { + result.copy(expiresIn = NAME_EXPIRE_DEPTH - (currentHeight - height)) + } else { + result + } } finally { runCatching { writer.close() } runCatching { reader.close() } @@ -279,6 +306,22 @@ class ElectrumxClient( return digest.reversedArray().joinToString("") { "%02x".format(it) } } + /** + * Parse the block height from a `blockchain.headers.subscribe` response. + * + * Response format: {"result": {"height": 814300, "hex": "..."}, ...} + */ + private fun parseBlockHeight(raw: String?): Int? { + if (raw == null) return null + return try { + val envelope = json.parseToJsonElement(raw).jsonObject + val result = envelope["result"]?.jsonObject ?: return null + result["height"]?.jsonPrimitive?.int + } catch (_: Exception) { + null + } + } + /** * Parse the history response into a list of (txHash, height) pairs. */ From afe126f393eb4a0d1c0fb1039ea08dc49febde57 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Tue, 3 Mar 2026 17:38:32 +0200 Subject: [PATCH 06/13] feat(desktop): add single-pane/deck toggle with smart column resizing - Add LayoutMode enum (SINGLE_PANE/DECK) persisted in DesktopPreferences - SinglePaneLayout: NavigationRail + single content area reusing deck routing - View menu "Deck Layout" toggle with Cmd+Shift+D shortcut - Column resize: redistribute width on add/remove, double-click header to expand - Constrain drag resize to window bounds via BoxWithConstraints - FlowRow headers in FeedScreen/ReadsScreen to wrap on narrow columns Co-Authored-By: Claude Opus 4.6 --- .../amethyst/desktop/DesktopPreferences.kt | 7 + .../vitorpamplona/amethyst/desktop/Main.kt | 256 +++++++++++------- .../amethyst/desktop/ui/FeedScreen.kt | 13 +- .../amethyst/desktop/ui/ReadsScreen.kt | 13 +- .../amethyst/desktop/ui/deck/ColumnHeader.kt | 9 +- .../desktop/ui/deck/DeckColumnContainer.kt | 6 +- .../amethyst/desktop/ui/deck/DeckLayout.kt | 65 ++--- .../amethyst/desktop/ui/deck/DeckState.kt | 89 +++++- .../desktop/ui/deck/SinglePaneLayout.kt | 231 ++++++++++++++++ 9 files changed, 543 insertions(+), 146 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt index 2dbcbbdd3..86b7e9d13 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt @@ -36,6 +36,7 @@ object DesktopPreferences { private const val KEY_FEED_MODE = "feed_mode" private const val KEY_LAST_SCREEN = "last_screen" private const val KEY_DECK_COLUMNS = "deck_columns" + private const val KEY_LAYOUT_MODE = "layout_mode" var feedMode: FeedMode get() { @@ -61,4 +62,10 @@ object DesktopPreferences { set(value) { prefs.put(KEY_DECK_COLUMNS, value) } + + var layoutMode: String + get() = prefs.get(KEY_LAYOUT_MODE, "SINGLE_PANE") + set(value) { + prefs.put(KEY_LAYOUT_MODE, value) + } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 86099ffdc..c69c65ae9 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -81,6 +81,7 @@ import com.vitorpamplona.amethyst.desktop.ui.deck.DeckColumnType import com.vitorpamplona.amethyst.desktop.ui.deck.DeckLayout import com.vitorpamplona.amethyst.desktop.ui.deck.DeckSidebar import com.vitorpamplona.amethyst.desktop.ui.deck.DeckState +import com.vitorpamplona.amethyst.desktop.ui.deck.SinglePaneLayout import com.vitorpamplona.amethyst.desktop.ui.profile.ProfileInfoCard import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect @@ -92,6 +93,11 @@ import kotlinx.coroutines.launch private val isMacOS = System.getProperty("os.name").lowercase().contains("mac") +enum class LayoutMode { + SINGLE_PANE, + DECK, +} + /** * Desktop navigation state — used for in-column navigation (drill-down). */ @@ -133,6 +139,15 @@ fun main() = var replyToNote by remember { mutableStateOf(null) } val deckState = remember { DeckState().also { it.load() } } var showAddColumnDialog by remember { mutableStateOf(false) } + var layoutMode by remember { + mutableStateOf( + try { + LayoutMode.valueOf(DesktopPreferences.layoutMode) + } catch (e: Exception) { + LayoutMode.SINGLE_PANE + }, + ) + } Window( onCloseRequest = ::exitApplication, @@ -198,100 +213,117 @@ fun main() = } Menu("View") { Item( - "Add Column", + if (layoutMode == LayoutMode.DECK) "\u2713 Deck Layout" else "Deck Layout", shortcut = if (isMacOS) { - KeyShortcut(Key.T, meta = true) + KeyShortcut(Key.D, meta = true, shift = true) } else { - KeyShortcut(Key.T, ctrl = true) - }, - onClick = { showAddColumnDialog = true }, - ) - Item( - "Close Column", - shortcut = - if (isMacOS) { - KeyShortcut(Key.W, meta = true) - } else { - KeyShortcut(Key.W, ctrl = true) + KeyShortcut(Key.D, ctrl = true, shift = true) }, onClick = { - val cols = deckState.columns.value - val idx = deckState.focusedColumnIndex.value - if (cols.size > 1 && idx in cols.indices) { - deckState.removeColumn(cols[idx].id) - } + layoutMode = + if (layoutMode == LayoutMode.DECK) LayoutMode.SINGLE_PANE else LayoutMode.DECK + DesktopPreferences.layoutMode = layoutMode.name }, ) - Item( - "Move Column Left", - shortcut = - if (isMacOS) { - KeyShortcut(Key.DirectionLeft, meta = true, shift = true) - } else { - KeyShortcut(Key.DirectionLeft, ctrl = true, shift = true) - }, - onClick = { - val idx = deckState.focusedColumnIndex.value - if (idx > 0) { - deckState.moveColumn(idx, idx - 1) - deckState.focusColumn(idx - 1) - } - }, - ) - Item( - "Move Column Right", - shortcut = - if (isMacOS) { - KeyShortcut(Key.DirectionRight, meta = true, shift = true) - } else { - KeyShortcut(Key.DirectionRight, ctrl = true, shift = true) - }, - onClick = { - val idx = deckState.focusedColumnIndex.value - val size = deckState.columns.value.size - if (idx < size - 1) { - deckState.moveColumn(idx, idx + 1) - deckState.focusColumn(idx + 1) - } - }, - ) - Separator() - // Focus column by index (Cmd/Ctrl+1..9) - val columnKeys = - listOf( - Key.One, - Key.Two, - Key.Three, - Key.Four, - Key.Five, - Key.Six, - Key.Seven, - Key.Eight, - Key.Nine, - ) - columnKeys.forEachIndexed { i, key -> + if (layoutMode == LayoutMode.DECK) { + Separator() Item( - "Column ${i + 1}", + "Add Column", shortcut = if (isMacOS) { - KeyShortcut(key, meta = true) + KeyShortcut(Key.T, meta = true) } else { - KeyShortcut(key, ctrl = true) + KeyShortcut(Key.T, ctrl = true) }, - onClick = { deckState.focusColumn(i) }, + onClick = { showAddColumnDialog = true }, ) - } - Separator() - Menu("Add Column...") { - Item("Home Feed", onClick = { deckState.addColumn(DeckColumnType.HomeFeed) }) - Item("Notifications", onClick = { deckState.addColumn(DeckColumnType.Notifications) }) - Item("Messages", onClick = { deckState.addColumn(DeckColumnType.Messages) }) - Item("Search", onClick = { deckState.addColumn(DeckColumnType.Search) }) - Item("Reads", onClick = { deckState.addColumn(DeckColumnType.Reads) }) - Item("Bookmarks", onClick = { deckState.addColumn(DeckColumnType.Bookmarks) }) - Item("Global Feed", onClick = { deckState.addColumn(DeckColumnType.GlobalFeed) }) - Item("Profile", onClick = { deckState.addColumn(DeckColumnType.MyProfile) }) + Item( + "Close Column", + shortcut = + if (isMacOS) { + KeyShortcut(Key.W, meta = true) + } else { + KeyShortcut(Key.W, ctrl = true) + }, + onClick = { + val cols = deckState.columns.value + val idx = deckState.focusedColumnIndex.value + if (cols.size > 1 && idx in cols.indices) { + deckState.removeColumn(cols[idx].id) + } + }, + ) + Item( + "Move Column Left", + shortcut = + if (isMacOS) { + KeyShortcut(Key.DirectionLeft, meta = true, shift = true) + } else { + KeyShortcut(Key.DirectionLeft, ctrl = true, shift = true) + }, + onClick = { + val idx = deckState.focusedColumnIndex.value + if (idx > 0) { + deckState.moveColumn(idx, idx - 1) + deckState.focusColumn(idx - 1) + } + }, + ) + Item( + "Move Column Right", + shortcut = + if (isMacOS) { + KeyShortcut(Key.DirectionRight, meta = true, shift = true) + } else { + KeyShortcut(Key.DirectionRight, ctrl = true, shift = true) + }, + onClick = { + val idx = deckState.focusedColumnIndex.value + val size = deckState.columns.value.size + if (idx < size - 1) { + deckState.moveColumn(idx, idx + 1) + deckState.focusColumn(idx + 1) + } + }, + ) + Separator() + // Focus column by index (Cmd/Ctrl+1..9) + val columnKeys = + listOf( + Key.One, + Key.Two, + Key.Three, + Key.Four, + Key.Five, + Key.Six, + Key.Seven, + Key.Eight, + Key.Nine, + ) + columnKeys.forEachIndexed { i, key -> + Item( + "Column ${i + 1}", + shortcut = + if (isMacOS) { + KeyShortcut(key, meta = true) + } else { + KeyShortcut(key, ctrl = true) + }, + onClick = { deckState.focusColumn(i) }, + ) + } + Separator() + Menu("Add Column...") { + Item("Home Feed", onClick = { deckState.addColumn(DeckColumnType.HomeFeed) }) + Item("Notifications", onClick = { deckState.addColumn(DeckColumnType.Notifications) }) + Item("Messages", onClick = { deckState.addColumn(DeckColumnType.Messages) }) + Item("Search", onClick = { deckState.addColumn(DeckColumnType.Search) }) + Item("Reads", onClick = { deckState.addColumn(DeckColumnType.Reads) }) + Item("Bookmarks", onClick = { deckState.addColumn(DeckColumnType.Bookmarks) }) + Item("Global Feed", onClick = { deckState.addColumn(DeckColumnType.GlobalFeed) }) + Item("Profile", onClick = { deckState.addColumn(DeckColumnType.MyProfile) }) + } } } Menu("Help") { @@ -301,6 +333,7 @@ fun main() = } App( + layoutMode = layoutMode, deckState = deckState, showComposeDialog = showComposeDialog, showAddColumnDialog = showAddColumnDialog, @@ -322,6 +355,7 @@ fun main() = @Composable fun App( + layoutMode: LayoutMode, deckState: DeckState, showComposeDialog: Boolean, showAddColumnDialog: Boolean, @@ -425,6 +459,7 @@ fun App( } MainContent( + layoutMode = layoutMode, deckState = deckState, relayManager = relayManager, localCache = localCache, @@ -466,6 +501,7 @@ fun App( @Composable fun MainContent( + layoutMode: LayoutMode, deckState: DeckState, relayManager: DesktopRelayConnectionManager, localCache: DesktopLocalCache, @@ -497,27 +533,47 @@ fun MainContent( Box(Modifier.fillMaxSize()) { Row(Modifier.fillMaxSize()) { - DeckSidebar( - onAddColumn = onShowAddColumnDialog, - onOpenSettings = { deckState.addColumn(DeckColumnType.Settings) }, - ) + when (layoutMode) { + LayoutMode.SINGLE_PANE -> { + SinglePaneLayout( + relayManager = relayManager, + localCache = localCache, + accountManager = accountManager, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + appScope = appScope, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onZapFeedback = onZapFeedback, + modifier = Modifier.weight(1f), + ) + } - VerticalDivider() + LayoutMode.DECK -> { + DeckSidebar( + onAddColumn = onShowAddColumnDialog, + onOpenSettings = { deckState.addColumn(DeckColumnType.Settings) }, + ) - DeckLayout( - deckState = deckState, - relayManager = relayManager, - localCache = localCache, - accountManager = accountManager, - account = account, - nwcConnection = nwcConnection, - subscriptionsCoordinator = subscriptionsCoordinator, - appScope = appScope, - onShowComposeDialog = onShowComposeDialog, - onShowReplyDialog = onShowReplyDialog, - onZapFeedback = onZapFeedback, - modifier = Modifier.weight(1f), - ) + VerticalDivider() + + DeckLayout( + deckState = deckState, + relayManager = relayManager, + localCache = localCache, + accountManager = accountManager, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + appScope = appScope, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onZapFeedback = onZapFeedback, + modifier = Modifier.weight(1f), + ) + } + } } // Snackbar for zap feedback diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 224971776..32d262282 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.desktop.ui import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -475,16 +477,17 @@ fun FeedScreen( ) } + @OptIn(ExperimentalLayoutApi::class) Column(modifier = Modifier.fillMaxSize()) { - // Header with compose button - Row( + // Header with compose button — wraps on narrow columns + FlowRow( modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + verticalArrangement = Arrangement.spacedBy(8.dp), ) { Column { - Row( - verticalAlignment = Alignment.CenterVertically, + FlowRow( + verticalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Text( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt index c1096316e..11121055b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.desktop.ui import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -259,16 +261,17 @@ fun ReadsScreen( } } + @OptIn(ExperimentalLayoutApi::class) Column(modifier = Modifier.fillMaxSize()) { - // Header - Row( + // Header — wraps on narrow columns + FlowRow( modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + verticalArrangement = Arrangement.spacedBy(8.dp), ) { Column { - Row( - verticalAlignment = Alignment.CenterVertically, + FlowRow( + verticalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Text( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt index 91ac19edb..d289cbb7f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.desktop.ui.deck import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -48,6 +49,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -58,6 +60,7 @@ fun ColumnHeader( hasBackStack: Boolean, onBack: () -> Unit, onClose: () -> Unit, + onDoubleClick: () -> Unit = {}, modifier: Modifier = Modifier, ) { Row( @@ -66,7 +69,11 @@ fun ColumnHeader( .fillMaxWidth() .height(40.dp) .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) - .padding(horizontal = 8.dp), + .pointerInput(Unit) { + detectTapGestures( + onDoubleTap = { onDoubleClick() }, + ) + }.padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { if (hasBackStack) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index bdcdee177..601e75d8a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -80,6 +80,7 @@ fun DeckColumnContainer( column: DeckColumn, canClose: Boolean, onClose: () -> Unit, + onDoubleClickHeader: () -> Unit = {}, relayManager: DesktopRelayConnectionManager, localCache: DesktopLocalCache, accountManager: AccountManager, @@ -108,6 +109,7 @@ fun DeckColumnContainer( hasBackStack = navStack.isNotEmpty(), onBack = { navState.pop() }, onClose = onClose, + onDoubleClick = onDoubleClickHeader, ) HorizontalDivider() @@ -152,7 +154,7 @@ fun DeckColumnContainer( } @Composable -private fun RootContent( +internal fun RootContent( columnType: DeckColumnType, relayManager: DesktopRelayConnectionManager, localCache: DesktopLocalCache, @@ -314,7 +316,7 @@ private fun RootContent( } @Composable -private fun OverlayContent( +internal fun OverlayContent( screen: DesktopScreen, relayManager: DesktopRelayConnectionManager, localCache: DesktopLocalCache, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt index 7ed4473d6..902b8871d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt @@ -21,13 +21,12 @@ package com.vitorpamplona.amethyst.desktop.ui.deck import androidx.compose.foundation.gestures.detectDragGestures -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState import androidx.compose.material3.MaterialTheme import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable @@ -37,6 +36,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.desktop.account.AccountManager import com.vitorpamplona.amethyst.desktop.account.AccountState @@ -64,38 +64,41 @@ fun DeckLayout( modifier: Modifier = Modifier, ) { val columns by deckState.columns.collectAsState() - val scrollState = rememberScrollState() + val density = LocalDensity.current - Row( - modifier = modifier.fillMaxSize().horizontalScroll(scrollState), - ) { - columns.forEachIndexed { index, column -> - if (index > 0) { - DraggableDivider( - onDrag = { delta -> - val leftCol = columns[index - 1] - val rightCol = columns[index] - deckState.updateColumnWidth(leftCol.id, leftCol.width + delta) - deckState.updateColumnWidth(rightCol.id, rightCol.width - delta) - }, + BoxWithConstraints(modifier = modifier.fillMaxSize()) { + val availableWidthDp = with(density) { constraints.maxWidth.toDp().value } + deckState.setAvailableWidth(availableWidthDp) + + Row(modifier = Modifier.fillMaxSize()) { + columns.forEachIndexed { index, column -> + if (index > 0) { + DraggableDivider( + onDrag = { delta -> + val leftCol = columns[index - 1] + val rightCol = columns[index] + deckState.resizePair(leftCol.id, rightCol.id, delta, availableWidthDp) + }, + ) + } + + DeckColumnContainer( + column = column, + canClose = columns.size > 1, + onClose = { deckState.removeColumn(column.id) }, + onDoubleClickHeader = { deckState.expandColumn(column.id, availableWidthDp) }, + relayManager = relayManager, + localCache = localCache, + accountManager = accountManager, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + appScope = appScope, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onZapFeedback = onZapFeedback, ) } - - DeckColumnContainer( - column = column, - canClose = columns.size > 1, - onClose = { deckState.removeColumn(column.id) }, - relayManager = relayManager, - localCache = localCache, - accountManager = accountManager, - account = account, - nwcConnection = nwcConnection, - subscriptionsCoordinator = subscriptionsCoordinator, - appScope = appScope, - onShowComposeDialog = onShowComposeDialog, - onShowReplyDialog = onShowReplyDialog, - onZapFeedback = onZapFeedback, - ) } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt index aa4370177..47e136467 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt @@ -34,6 +34,12 @@ class DeckState { private val _focusedColumnIndex = MutableStateFlow(0) val focusedColumnIndex: StateFlow = _focusedColumnIndex.asStateFlow() + private var lastKnownWidth: Float = 0f + + fun setAvailableWidth(width: Float) { + lastKnownWidth = width + } + fun addColumn( type: DeckColumnType, afterIndex: Int? = null, @@ -45,12 +51,24 @@ class DeckState { } else { _columns.value + col } - save() + // Auto-fit all columns to available width when known + if (lastKnownWidth > 0f) { + fitColumnsToWidth(lastKnownWidth) + } else { + save() + } } fun removeColumn(id: String) { if (_columns.value.size <= 1) return - _columns.value = _columns.value.filter { it.id != id } + val removed = _columns.value.find { it.id == id } ?: return + val remaining = _columns.value.filter { it.id != id } + // Redistribute removed column's width evenly across remaining columns + val extra = removed.width / remaining.size + _columns.value = + remaining.map { + it.copy(width = (it.width + extra).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)) + } if (_focusedColumnIndex.value >= _columns.value.size) { _focusedColumnIndex.value = _columns.value.size - 1 } @@ -80,6 +98,72 @@ class DeckState { save() } + fun expandColumn( + id: String, + availableWidth: Float, + ) { + val cols = _columns.value + val target = cols.find { it.id == id } ?: return + val others = cols.filter { it.id != id } + // Shrink others to minimum, give the rest to the target + val othersMin = others.size * MIN_COLUMN_WIDTH + val dividerWidth = (cols.size - 1) * DIVIDER_WIDTH + val maxForTarget = (availableWidth - othersMin - dividerWidth).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH) + _columns.value = + cols.map { + if (it.id == id) { + it.copy(width = maxForTarget) + } else { + it.copy(width = MIN_COLUMN_WIDTH) + } + } + save() + } + + fun resizePair( + leftId: String, + rightId: String, + delta: Float, + availableWidth: Float, + ) { + val cols = _columns.value + val left = cols.find { it.id == leftId } ?: return + val right = cols.find { it.id == rightId } ?: return + // Compute max total allowed (available minus other columns and dividers) + val otherWidth = cols.filter { it.id != leftId && it.id != rightId }.sumOf { it.width.toDouble() }.toFloat() + val dividerWidth = (cols.size - 1) * DIVIDER_WIDTH + val maxPairWidth = availableWidth - otherWidth - dividerWidth + var newLeft = (left.width + delta).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH) + var newRight = (right.width - delta).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH) + // Ensure pair doesn't exceed available space + if (newLeft + newRight > maxPairWidth) { + if (delta > 0) { + newLeft = (maxPairWidth - newRight).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH) + } else { + newRight = (maxPairWidth - newLeft).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH) + } + } + _columns.value = + cols.map { + when (it.id) { + leftId -> it.copy(width = newLeft) + rightId -> it.copy(width = newRight) + else -> it + } + } + save() + } + + fun fitColumnsToWidth(availableWidth: Float) { + val cols = _columns.value + if (cols.isEmpty()) return + val dividers = (cols.size - 1) * DIVIDER_WIDTH + val usable = availableWidth - dividers + val perColumn = (usable / cols.size).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH) + _columns.value = cols.map { it.copy(width = perColumn) } + save() + } + fun focusColumn(index: Int) { if (index in _columns.value.indices) { _focusedColumnIndex.value = index @@ -130,6 +214,7 @@ class DeckState { companion object { const val MIN_COLUMN_WIDTH = 300f const val MAX_COLUMN_WIDTH = 800f + const val DIVIDER_WIDTH = 4f val DEFAULT_COLUMNS = listOf( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt new file mode 100644 index 000000000..35cfbda07 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt @@ -0,0 +1,231 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.deck + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Article +import androidx.compose.material.icons.filled.Bookmark +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationRail +import androidx.compose.material3.NavigationRailItem +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.desktop.DesktopScreen +import com.vitorpamplona.amethyst.desktop.account.AccountManager +import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator +import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm +import kotlinx.coroutines.CoroutineScope + +private data class NavItem( + val type: DeckColumnType, + val icon: ImageVector, + val label: String, +) + +private val navItems = + listOf( + NavItem(DeckColumnType.HomeFeed, Icons.Default.Home, "Home"), + NavItem(DeckColumnType.Reads, Icons.AutoMirrored.Filled.Article, "Reads"), + NavItem(DeckColumnType.Search, Icons.Default.Search, "Search"), + NavItem(DeckColumnType.Bookmarks, Icons.Default.Bookmark, "Bookmarks"), + NavItem(DeckColumnType.Messages, Icons.Default.Email, "Messages"), + NavItem(DeckColumnType.Notifications, Icons.Default.Notifications, "Notifications"), + NavItem(DeckColumnType.MyProfile, Icons.Default.Person, "Profile"), + NavItem(DeckColumnType.Settings, Icons.Default.Settings, "Settings"), + ) + +@Composable +fun SinglePaneLayout( + relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, + accountManager: AccountManager, + account: AccountState.LoggedIn, + nwcConnection: Nip47URINorm?, + subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, + appScope: CoroutineScope, + onShowComposeDialog: () -> Unit, + onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, + onZapFeedback: (ZapFeedback) -> Unit, + modifier: Modifier = Modifier, +) { + var currentColumnType by remember { mutableStateOf(DeckColumnType.HomeFeed) } + val navState = remember { ColumnNavigationState() } + val navStack by navState.stack.collectAsState() + val currentOverlay = navStack.lastOrNull() + + Row(modifier = modifier.fillMaxSize()) { + NavigationRail( + modifier = Modifier.width(80.dp).fillMaxHeight(), + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ) { + navItems.forEach { item -> + NavigationRailItem( + selected = currentColumnType == item.type && navStack.isEmpty(), + onClick = { + currentColumnType = item.type + navState.clear() + }, + icon = { + Icon( + item.icon, + contentDescription = item.label, + modifier = Modifier.size(22.dp), + ) + }, + label = { + Text( + item.label, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + ) + } + } + + VerticalDivider() + + Column(modifier = Modifier.weight(1f).fillMaxHeight()) { + // Show header with back button when navigated into overlay + if (navStack.isNotEmpty()) { + SinglePaneHeader( + title = + when (currentOverlay) { + is DesktopScreen.UserProfile -> "Profile" + is DesktopScreen.Thread -> "Thread" + else -> currentColumnType.title() + }, + onBack = { navState.pop() }, + ) + HorizontalDivider() + } + + Box( + modifier = Modifier.fillMaxSize().padding(12.dp), + ) { + if (currentOverlay != null) { + OverlayContent( + screen = currentOverlay, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onZapFeedback = onZapFeedback, + onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, + onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, + onBack = { navState.pop() }, + ) + } else { + RootContent( + columnType = currentColumnType, + relayManager = relayManager, + localCache = localCache, + accountManager = accountManager, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + appScope = appScope, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onZapFeedback = onZapFeedback, + onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, + onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, + ) + } + } + } + } +} + +@Composable +private fun SinglePaneHeader( + title: String, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .height(40.dp) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onBack, modifier = Modifier.size(28.dp)) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.width(8.dp)) + Text( + text = title, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} From c649f2163b309f24ef59b4e464e2c46c9a77b8db Mon Sep 17 00:00:00 2001 From: M Date: Wed, 4 Mar 2026 06:30:50 +1100 Subject: [PATCH 07/13] Add nmc2.bitcoins.sk and 46.229.238.187 as backup ElectrumX servers Both verified to resolve Namecoin names via scripthash lookups. Port 57002, TLS with self-signed certs. --- .../com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt index 2e3cf2e1a..efbba9349 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt @@ -115,6 +115,8 @@ class ElectrumxClient( ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, trustAllCerts = true), ElectrumxServer("ulrichard.ch", 50006, useSsl = true), ElectrumxServer("nmc2.lelux.fi", 50006, useSsl = true), + ElectrumxServer("nmc2.bitcoins.sk", 57002, useSsl = true, trustAllCerts = true), + ElectrumxServer("46.229.238.187", 57002, useSsl = true, trustAllCerts = true), ) /** Tor-preferred server list: onion primary, clearnet fallback. */ @@ -127,6 +129,7 @@ class ElectrumxClient( trustAllCerts = true, ), ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, trustAllCerts = true), + ElectrumxServer("nmc2.bitcoins.sk", 57002, useSsl = true, trustAllCerts = true), ) private const val PROTOCOL_VERSION = "1.4" From cb1031705bdba1806018e5dbec83a4a1728bfe7c Mon Sep 17 00:00:00 2001 From: M Date: Wed, 4 Mar 2026 06:34:39 +1100 Subject: [PATCH 08/13] Remove dead ElectrumX servers ulrichard.ch and nmc2.lelux.fi MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ulrichard.ch:50006 — connects but never responds (timeout) nmc2.lelux.fi:50006 — DNS resolution fails (ENOTFOUND) --- .../com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt index efbba9349..6e63b785e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt @@ -113,8 +113,6 @@ class ElectrumxClient( val DEFAULT_SERVERS = listOf( ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, trustAllCerts = true), - ElectrumxServer("ulrichard.ch", 50006, useSsl = true), - ElectrumxServer("nmc2.lelux.fi", 50006, useSsl = true), ElectrumxServer("nmc2.bitcoins.sk", 57002, useSsl = true, trustAllCerts = true), ElectrumxServer("46.229.238.187", 57002, useSsl = true, trustAllCerts = true), ) From 2df8a106d498766ff56d4ff74e47534910895ecb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Mar 2026 00:51:43 +0000 Subject: [PATCH 09/13] refactor: restructure nip64Chess to match nip88Polls pattern Reorganize nip64Chess event classes into individual packages with dedicated tag classes, TagArrayBuilderExt, and TagArrayExt files, following the same structure used by nip88Polls. New package structure: - challenge/ - LiveChessGameChallengeEvent with PlayerColorTag, TimeControlTag - accept/ - LiveChessGameAcceptEvent with ChallengeEventTag - move/ - LiveChessMoveEvent with GameIdTag, MoveNumberTag, SanTag, FenTag - end/ - LiveChessGameEndEvent with ResultTag, TerminationTag, WinnerTag - draw/ - LiveChessDrawOfferEvent - game/ - ChessGameEvent (Kind 64) - jester/ - JesterEvent, JesterProtocol, JesterContent, JesterGameEvents Each tag class follows the companion object pattern with isTag(), parse(), and assemble() methods. Each event package includes TagArrayBuilderExt for building and TagArrayExt for parsing. https://claude.ai/code/session_01Qtzhka3p5N3Hbns4xrRbsv --- .../amethyst/model/LocalCache.kt | 14 +- .../amethyst/model/nip64Chess/ChessAction.kt | 8 +- .../topNavFeeds/chess/ChessTopNavFilter.kt | 6 +- .../amethyst/ui/note/NoteCompose.kt | 6 +- .../amethyst/ui/note/types/Chess.kt | 6 +- .../loggedIn/chess/AndroidChessAdapter.kt | 8 +- .../loggedIn/chess/ChessViewModelNew.kt | 4 +- .../loggedIn/chess/dal/ChessFeedFilter.kt | 2 +- .../home/dal/HomeNewThreadFeedFilter.kt | 6 +- .../nip64Chess/FilterHomePostsByChess.kt | 6 +- .../nip65Follows/FilterHomePostsByAuthors.kt | 6 +- .../loggedIn/relays/RelayInformationScreen.kt | 14 +- .../commons/chess/ChessEventCollector.kt | 8 +- .../amethyst/commons/chess/ChessGameLoader.kt | 2 +- .../amethyst/commons/chess/ChessLobbyLogic.kt | 4 +- .../chess/subscription/ChessFilterBuilder.kt | 2 +- .../commons/chess/ChessEventBroadcaster.kt | 2 +- .../desktop/chess/DesktopChessAdapter.kt | 8 +- .../desktop/chess/DesktopChessEventCache.kt | 8 +- .../desktop/chess/DesktopChessViewModelNew.kt | 4 +- .../subscriptions/ChessSubscription.kt | 10 +- .../nip64Chess/ChessStateReconstructor.kt | 3 + .../quartz/nip64Chess/LiveChessGameEvents.kt | 295 ------------------ .../accept/LiveChessGameAcceptEvent.kt | 72 +++++ .../nip64Chess/accept/TagArrayBuilderExt.kt | 26 ++ .../quartz/nip64Chess/accept/TagArrayExt.kt | 26 ++ .../accept/tags/ChallengeEventTag.kt | 40 +++ .../challenge/LiveChessGameChallengeEvent.kt | 79 +++++ .../challenge/TagArrayBuilderExt.kt | 30 ++ .../nip64Chess/challenge/TagArrayExt.kt | 29 ++ .../challenge/tags/PlayerColorTag.kt | 45 +++ .../challenge/tags/TimeControlTag.kt | 40 +++ .../draw/LiveChessDrawOfferEvent.kt | 73 +++++ .../nip64Chess/end/LiveChessGameEndEvent.kt | 89 ++++++ .../nip64Chess/end/TagArrayBuilderExt.kt | 35 +++ .../quartz/nip64Chess/end/TagArrayExt.kt | 32 ++ .../quartz/nip64Chess/end/tags/ResultTag.kt | 41 +++ .../nip64Chess/end/tags/TerminationTag.kt | 41 +++ .../quartz/nip64Chess/end/tags/WinnerTag.kt | 41 +++ .../nip64Chess/{ => game}/ChessGameEvent.kt | 22 +- .../quartz/nip64Chess/jester/JesterContent.kt | 40 +++ .../JesterEvent.kt} | 88 +----- .../nip64Chess/jester/JesterGameEvents.kt | 48 +++ .../nip64Chess/jester/JesterProtocol.kt | 59 ++++ .../nip64Chess/move/LiveChessMoveEvent.kt | 90 ++++++ .../nip64Chess/move/TagArrayBuilderExt.kt | 35 +++ .../quartz/nip64Chess/move/TagArrayExt.kt | 35 +++ .../quartz/nip64Chess/move/tags/FenTag.kt | 40 +++ .../quartz/nip64Chess/move/tags/GameIdTag.kt | 40 +++ .../nip64Chess/move/tags/MoveNumberTag.kt | 40 +++ .../quartz/nip64Chess/move/tags/SanTag.kt | 40 +++ .../quartz/utils/EventFactory.kt | 14 +- 52 files changed, 1298 insertions(+), 464 deletions(-) delete mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessGameEvents.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/accept/LiveChessGameAcceptEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/accept/TagArrayBuilderExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/accept/TagArrayExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/accept/tags/ChallengeEventTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/challenge/LiveChessGameChallengeEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/challenge/TagArrayBuilderExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/challenge/TagArrayExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/challenge/tags/PlayerColorTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/challenge/tags/TimeControlTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/draw/LiveChessDrawOfferEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/LiveChessGameEndEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/TagArrayBuilderExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/TagArrayExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/tags/ResultTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/tags/TerminationTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/tags/WinnerTag.kt rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/{ => game}/ChessGameEvent.kt (98%) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/jester/JesterContent.kt rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/{JesterEvents.kt => jester/JesterEvent.kt} (80%) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/jester/JesterGameEvents.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/jester/JesterProtocol.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/move/LiveChessMoveEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/move/TagArrayBuilderExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/move/TagArrayExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/move/tags/FenTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/move/tags/GameIdTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/move/tags/MoveNumberTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/move/tags/SanTag.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 5da610d01..ea26f2b6f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -169,13 +169,13 @@ import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent -import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent -import com.vitorpamplona.quartz.nip64Chess.JesterEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessDrawOfferEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent +import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent +import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent +import com.vitorpamplona.quartz.nip64Chess.draw.LiveChessDrawOfferEvent +import com.vitorpamplona.quartz.nip64Chess.accept.LiveChessGameAcceptEvent +import com.vitorpamplona.quartz.nip64Chess.challenge.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent +import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip64Chess/ChessAction.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip64Chess/ChessAction.kt index 143f6ce8b..82a966095 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip64Chess/ChessAction.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip64Chess/ChessAction.kt @@ -24,10 +24,10 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip64Chess.Color import com.vitorpamplona.quartz.nip64Chess.GameResult import com.vitorpamplona.quartz.nip64Chess.GameTermination -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent +import com.vitorpamplona.quartz.nip64Chess.accept.LiveChessGameAcceptEvent +import com.vitorpamplona.quartz.nip64Chess.challenge.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent +import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent /** * Action class for creating and signing live chess events diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/chess/ChessTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/chess/ChessTopNavFilter.kt index de820177d..597005256 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/chess/ChessTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/chess/ChessTopNavFilter.kt @@ -26,9 +26,9 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent +import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent +import com.vitorpamplona.quartz.nip64Chess.challenge.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 9a95f5a55..4fd832695 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -217,9 +217,9 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.splits.hasZapSplitSetup import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent -import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent +import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent +import com.vitorpamplona.quartz.nip64Chess.challenge.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt index ab22901ef..4c1a9da8f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt @@ -52,9 +52,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessViewModelFactory import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessViewModelNew import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent +import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent +import com.vitorpamplona.quartz.nip64Chess.challenge.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent import com.vitorpamplona.quartz.nip64Chess.Color as ChessColor /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/AndroidChessAdapter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/AndroidChessAdapter.kt index 18c639ec4..5a532819b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/AndroidChessAdapter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/AndroidChessAdapter.kt @@ -34,10 +34,10 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.quartz.nip64Chess.ChessGameEnd import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent import com.vitorpamplona.quartz.nip64Chess.Color -import com.vitorpamplona.quartz.nip64Chess.JesterEvent -import com.vitorpamplona.quartz.nip64Chess.JesterGameEvents -import com.vitorpamplona.quartz.nip64Chess.JesterProtocol -import com.vitorpamplona.quartz.nip64Chess.toJesterEvent +import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent +import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents +import com.vitorpamplona.quartz.nip64Chess.jester.JesterProtocol +import com.vitorpamplona.quartz.nip64Chess.jester.toJesterEvent /** * Android implementation of ChessEventPublisher using Jester protocol. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt index 7c7fef8b7..2e30e791a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt @@ -33,9 +33,9 @@ import com.vitorpamplona.amethyst.commons.chess.PublicGame import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip64Chess.Color -import com.vitorpamplona.quartz.nip64Chess.JesterProtocol +import com.vitorpamplona.quartz.nip64Chess.jester.JesterProtocol import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState -import com.vitorpamplona.quartz.nip64Chess.toJesterEvent +import com.vitorpamplona.quartz.nip64Chess.jester.toJesterEvent import kotlinx.coroutines.flow.StateFlow /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/dal/ChessFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/dal/ChessFeedFilter.kt index 26749dd38..883704540 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/dal/ChessFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/dal/ChessFeedFilter.kt @@ -26,7 +26,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.amethyst.ui.dal.FilterByListParams -import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent +import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent /** * Feed filter for NIP-64 Chess Game events (Kind 64) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt index fcf5941dc..864c5b194 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt @@ -39,9 +39,9 @@ import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent -import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent +import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent +import com.vitorpamplona.quartz.nip64Chess.challenge.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip64Chess/FilterHomePostsByChess.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip64Chess/FilterHomePostsByChess.kt index 3999badfc..dbde0f4c4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip64Chess/FilterHomePostsByChess.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip64Chess/FilterHomePostsByChess.kt @@ -24,9 +24,9 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.chess.ChessTopNavPerRelayFil import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent +import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent +import com.vitorpamplona.quartz.nip64Chess.challenge.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent val ChessPostsKinds = listOf( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt index fb9ca719e..7d80db5b1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt @@ -39,9 +39,9 @@ import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent -import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent +import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent +import com.vitorpamplona.quartz.nip64Chess.challenge.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt index 375f0dd12..19872675d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt @@ -221,13 +221,13 @@ import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent -import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent -import com.vitorpamplona.quartz.nip64Chess.JesterEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessDrawOfferEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent +import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent +import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent +import com.vitorpamplona.quartz.nip64Chess.draw.LiveChessDrawOfferEvent +import com.vitorpamplona.quartz.nip64Chess.accept.LiveChessGameAcceptEvent +import com.vitorpamplona.quartz.nip64Chess.challenge.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent +import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.RelayMonitorEvent diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventCollector.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventCollector.kt index c15195dbf..8a4575f3f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventCollector.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventCollector.kt @@ -21,10 +21,10 @@ package com.vitorpamplona.amethyst.commons.chess import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip64Chess.JesterEvent -import com.vitorpamplona.quartz.nip64Chess.JesterGameEvents -import com.vitorpamplona.quartz.nip64Chess.JesterProtocol -import com.vitorpamplona.quartz.nip64Chess.toJesterEvent +import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent +import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents +import com.vitorpamplona.quartz.nip64Chess.jester.JesterProtocol +import com.vitorpamplona.quartz.nip64Chess.jester.toJesterEvent import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessGameLoader.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessGameLoader.kt index f0557e2ab..0b4212e3e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessGameLoader.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessGameLoader.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.commons.chess import com.vitorpamplona.quartz.nip64Chess.ChessEngine import com.vitorpamplona.quartz.nip64Chess.ChessStateReconstructor import com.vitorpamplona.quartz.nip64Chess.Color -import com.vitorpamplona.quartz.nip64Chess.JesterGameEvents +import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState import com.vitorpamplona.quartz.nip64Chess.ReconstructedGameState import com.vitorpamplona.quartz.nip64Chess.ReconstructionResult diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt index 56ae30b3d..3a4043f93 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt @@ -23,8 +23,8 @@ package com.vitorpamplona.amethyst.commons.chess import com.vitorpamplona.quartz.nip64Chess.ChessGameEnd import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent import com.vitorpamplona.quartz.nip64Chess.Color -import com.vitorpamplona.quartz.nip64Chess.JesterEvent -import com.vitorpamplona.quartz.nip64Chess.JesterGameEvents +import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent +import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents import com.vitorpamplona.quartz.nip64Chess.PieceType import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CoroutineScope diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/subscription/ChessFilterBuilder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/subscription/ChessFilterBuilder.kt index 90ff1070e..4057a696e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/subscription/ChessFilterBuilder.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/subscription/ChessFilterBuilder.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.commons.chess.subscription import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip64Chess.JesterProtocol +import com.vitorpamplona.quartz.nip64Chess.jester.JesterProtocol import com.vitorpamplona.quartz.utils.TimeUtils /** diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventBroadcaster.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventBroadcaster.kt index ac6e7d896..59c37d4df 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventBroadcaster.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventBroadcaster.kt @@ -27,7 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip64Chess.JesterProtocol +import com.vitorpamplona.quartz.nip64Chess.jester.JesterProtocol import kotlinx.coroutines.delay /** diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessAdapter.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessAdapter.kt index f80a3f6a6..d20a2247b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessAdapter.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessAdapter.kt @@ -37,10 +37,10 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip64Chess.ChessGameEnd import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent import com.vitorpamplona.quartz.nip64Chess.Color -import com.vitorpamplona.quartz.nip64Chess.JesterEvent -import com.vitorpamplona.quartz.nip64Chess.JesterGameEvents -import com.vitorpamplona.quartz.nip64Chess.JesterProtocol -import com.vitorpamplona.quartz.nip64Chess.toJesterEvent +import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent +import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents +import com.vitorpamplona.quartz.nip64Chess.jester.JesterProtocol +import com.vitorpamplona.quartz.nip64Chess.jester.toJesterEvent /** * Desktop implementation of ChessEventPublisher using Jester protocol. diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessEventCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessEventCache.kt index c779afebd..0e9fd9a02 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessEventCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessEventCache.kt @@ -21,10 +21,10 @@ package com.vitorpamplona.amethyst.desktop.chess import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip64Chess.JesterEvent -import com.vitorpamplona.quartz.nip64Chess.JesterGameEvents -import com.vitorpamplona.quartz.nip64Chess.JesterProtocol -import com.vitorpamplona.quartz.nip64Chess.toJesterEvent +import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent +import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents +import com.vitorpamplona.quartz.nip64Chess.jester.JesterProtocol +import com.vitorpamplona.quartz.nip64Chess.jester.toJesterEvent import java.util.concurrent.ConcurrentHashMap /** diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt index 895aed70a..5958c3aa5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt @@ -32,9 +32,9 @@ import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip64Chess.Color -import com.vitorpamplona.quartz.nip64Chess.JesterProtocol +import com.vitorpamplona.quartz.nip64Chess.jester.JesterProtocol import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState -import com.vitorpamplona.quartz.nip64Chess.toJesterEvent +import com.vitorpamplona.quartz.nip64Chess.jester.toJesterEvent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.StateFlow diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ChessSubscription.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ChessSubscription.kt index 81d9bbde2..fa133d8a2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ChessSubscription.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ChessSubscription.kt @@ -30,11 +30,11 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip64Chess.LiveChessDrawOfferEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent +import com.vitorpamplona.quartz.nip64Chess.draw.LiveChessDrawOfferEvent +import com.vitorpamplona.quartz.nip64Chess.accept.LiveChessGameAcceptEvent +import com.vitorpamplona.quartz.nip64Chess.challenge.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent +import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructor.kt index 65849aa77..cb6542ce9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructor.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructor.kt @@ -21,6 +21,9 @@ package com.vitorpamplona.quartz.nip64Chess import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent +import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents +import com.vitorpamplona.quartz.nip64Chess.jester.JesterProtocol /** * Deterministic chess state reconstruction from Jester protocol events. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessGameEvents.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessGameEvents.kt deleted file mode 100644 index 4381f832c..000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessGameEvents.kt +++ /dev/null @@ -1,295 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.nip64Chess - -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder -import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate -import com.vitorpamplona.quartz.nip31Alts.alt -import com.vitorpamplona.quartz.utils.TimeUtils - -/** - * Live Chess Game Challenge Event (Kind 30064) - * - * Challenge another player to a chess game or create an open challenge. - * This is a parameterized replaceable event. - * - * Tags: - * - d: game_id (unique identifier for this game) - * - p: opponent pubkey (optional, for direct challenges) - * - player_color: "white" or "black" - * - time_control: optional time control (e.g., "10+0", "5+3") - */ -@Immutable -class LiveChessGameChallengeEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - companion object { - const val KIND = 30064 - - fun build( - gameId: String, - playerColor: Color, - opponentPubkey: String? = null, - timeControl: String? = null, - createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate(KIND, "", createdAt) { - add(arrayOf("d", gameId)) - add(arrayOf("player_color", if (playerColor == Color.WHITE) "white" else "black")) - opponentPubkey?.let { add(arrayOf("p", it)) } - timeControl?.let { add(arrayOf("time_control", it)) } - alt("Chess game challenge") - initializer() - } - } - - fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1) - - fun playerColor(): Color? = - tags - .firstOrNull { it.size >= 2 && it[0] == "player_color" } - ?.get(1) - ?.let { if (it == "white") Color.WHITE else Color.BLACK } - - fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1) - - fun timeControl(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "time_control" }?.get(1) -} - -/** - * Live Chess Game Accept Event (Kind 30065) - * - * Accept a chess game challenge - * - * Tags: - * - d: game_id (same as challenge) - * - e: challenge event ID - * - p: challenger pubkey - */ -@Immutable -class LiveChessGameAcceptEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - companion object { - const val KIND = 30065 - - fun build( - gameId: String, - challengeEventId: String, - challengerPubkey: String, - createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate(KIND, "", createdAt) { - add(arrayOf("d", gameId)) - add(arrayOf("e", challengeEventId)) - add(arrayOf("p", challengerPubkey)) - alt("Chess game acceptance") - initializer() - } - } - - fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1) - - fun challengeEventId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "e" }?.get(1) - - fun challengerPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1) -} - -/** - * Live Chess Move Event (Kind 30066) - * - * Individual move in a live chess game - * - * Tags: - * - d: game_id - * - move_number: move number (1-based) - * - san: move in Standard Algebraic Notation - * - fen: resulting position in FEN notation - * - p: opponent pubkey - * - * Content: Optional move comment - */ -@Immutable -class LiveChessMoveEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - companion object { - const val KIND = 30066 - - fun build( - gameId: String, - moveNumber: Int, - san: String, - fen: String, - opponentPubkey: String, - comment: String = "", - createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate(KIND, comment, createdAt) { - add(arrayOf("d", "$gameId-$moveNumber")) - add(arrayOf("game_id", gameId)) - add(arrayOf("move_number", moveNumber.toString())) - add(arrayOf("san", san)) - add(arrayOf("fen", fen)) - add(arrayOf("p", opponentPubkey)) - alt("Chess move: $san") - initializer() - } - } - - fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "game_id" }?.get(1) - - fun moveNumber(): Int? = - tags - .firstOrNull { it.size >= 2 && it[0] == "move_number" } - ?.get(1) - ?.toIntOrNull() - - fun san(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "san" }?.get(1) - - fun fen(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "fen" }?.get(1) - - fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1) - - fun comment(): String = content -} - -/** - * Live Chess Game End Event (Kind 30067) - * - * Game result and termination - * - * Tags: - * - d: game_id - * - result: "1-0"|"0-1"|"1/2-1/2" - * - termination: reason for game end - * - winner: pubkey of winner (if applicable) - * - p: opponent pubkey - * - * Content: Optional PGN of complete game - */ -@Immutable -class LiveChessGameEndEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - companion object { - const val KIND = 30067 - - fun build( - gameId: String, - result: GameResult, - termination: GameTermination, - winnerPubkey: String? = null, - opponentPubkey: String, - pgn: String = "", - createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate(KIND, pgn, createdAt) { - add(arrayOf("d", gameId)) - add(arrayOf("result", result.notation)) - add(arrayOf("termination", termination.name.lowercase())) - winnerPubkey?.let { add(arrayOf("winner", it)) } - add(arrayOf("p", opponentPubkey)) - alt("Chess game ended: ${result.notation}") - initializer() - } - } - - fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1) - - fun result(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "result" }?.get(1) - - fun termination(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "termination" }?.get(1) - - fun winnerPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "winner" }?.get(1) - - fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1) - - fun pgn(): String = content -} - -/** - * Live Chess Draw Offer Event (Kind 30068) - * - * Offer a draw to opponent. Opponent can accept by sending a game end event - * with DRAW_AGREEMENT termination, or decline/ignore by making their next move. - * - * Tags: - * - d: game_id - * - p: opponent pubkey - * - * Content: Optional message - */ -@Immutable -class LiveChessDrawOfferEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - companion object { - const val KIND = 30068 - - fun build( - gameId: String, - opponentPubkey: String, - message: String = "", - createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate(KIND, message, createdAt) { - add(arrayOf("d", gameId)) - add(arrayOf("p", opponentPubkey)) - alt("Chess draw offer") - initializer() - } - } - - fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1) - - fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1) - - fun message(): String = content -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/accept/LiveChessGameAcceptEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/accept/LiveChessGameAcceptEvent.kt new file mode 100644 index 000000000..16872f468 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/accept/LiveChessGameAcceptEvent.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess.accept + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Live Chess Game Accept Event (Kind 30065) + * + * Accept a chess game challenge + * + * Tags: + * - d: game_id (same as challenge) + * - e: challenge event ID + * - p: challenger pubkey + */ +@Immutable +class LiveChessGameAcceptEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun challengeEventId() = tags.challengeEventId() + + fun challengerPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1) + + companion object { + const val KIND = 30065 + const val ALT_DESCRIPTION = "Chess game acceptance" + + fun build( + gameId: String, + challengeEventId: String, + challengerPubkey: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + add(arrayOf("d", gameId)) + challengeEvent(challengeEventId) + add(arrayOf("p", challengerPubkey)) + alt(ALT_DESCRIPTION) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/accept/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/accept/TagArrayBuilderExt.kt new file mode 100644 index 000000000..4f0e0391c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/accept/TagArrayBuilderExt.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess.accept + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip64Chess.accept.tags.ChallengeEventTag + +fun TagArrayBuilder.challengeEvent(challengeEventId: String) = add(ChallengeEventTag.assemble(challengeEventId)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/accept/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/accept/TagArrayExt.kt new file mode 100644 index 000000000..86a529305 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/accept/TagArrayExt.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess.accept + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip64Chess.accept.tags.ChallengeEventTag + +fun TagArray.challengeEventId() = firstNotNullOfOrNull(ChallengeEventTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/accept/tags/ChallengeEventTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/accept/tags/ChallengeEventTag.kt new file mode 100644 index 000000000..803b0e869 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/accept/tags/ChallengeEventTag.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess.accept.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class ChallengeEventTag { + companion object { + const val TAG_NAME = "e" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].length == 64 + + fun parse(tag: Array): String? { + ensure(tag.has(1) && tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + return tag[1] + } + + fun assemble(challengeEventId: String) = arrayOf(TAG_NAME, challengeEventId) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/challenge/LiveChessGameChallengeEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/challenge/LiveChessGameChallengeEvent.kt new file mode 100644 index 000000000..98cc21fa7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/challenge/LiveChessGameChallengeEvent.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess.challenge + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip64Chess.Color +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Live Chess Game Challenge Event (Kind 30064) + * + * Challenge another player to a chess game or create an open challenge. + * This is a parameterized replaceable event. + * + * Tags: + * - d: game_id (unique identifier for this game) + * - p: opponent pubkey (optional, for direct challenges) + * - player_color: "white" or "black" + * - time_control: optional time control (e.g., "10+0", "5+3") + */ +@Immutable +class LiveChessGameChallengeEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun playerColor() = tags.playerColor() + + fun timeControl() = tags.timeControl() + + fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1) + + companion object { + const val KIND = 30064 + const val ALT_DESCRIPTION = "Chess game challenge" + + fun build( + gameId: String, + playerColor: Color, + opponentPubkey: String? = null, + timeControl: String? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + add(arrayOf("d", gameId)) + playerColor(playerColor) + opponentPubkey?.let { add(arrayOf("p", it)) } + timeControl?.let { timeControl(it) } + alt(ALT_DESCRIPTION) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/challenge/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/challenge/TagArrayBuilderExt.kt new file mode 100644 index 000000000..a110d2405 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/challenge/TagArrayBuilderExt.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess.challenge + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip64Chess.Color +import com.vitorpamplona.quartz.nip64Chess.challenge.tags.PlayerColorTag +import com.vitorpamplona.quartz.nip64Chess.challenge.tags.TimeControlTag + +fun TagArrayBuilder.playerColor(color: Color) = addUnique(PlayerColorTag.assemble(color)) + +fun TagArrayBuilder.timeControl(timeControl: String) = addUnique(TimeControlTag.assemble(timeControl)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/challenge/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/challenge/TagArrayExt.kt new file mode 100644 index 000000000..01a247b87 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/challenge/TagArrayExt.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess.challenge + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip64Chess.challenge.tags.PlayerColorTag +import com.vitorpamplona.quartz.nip64Chess.challenge.tags.TimeControlTag + +fun TagArray.playerColor() = firstNotNullOfOrNull(PlayerColorTag::parse) + +fun TagArray.timeControl() = firstNotNullOfOrNull(TimeControlTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/challenge/tags/PlayerColorTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/challenge/tags/PlayerColorTag.kt new file mode 100644 index 000000000..983ab756d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/challenge/tags/PlayerColorTag.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess.challenge.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip64Chess.Color +import com.vitorpamplona.quartz.utils.ensure + +class PlayerColorTag { + companion object { + const val TAG_NAME = "player_color" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): Color? { + ensure(tag.has(1) && tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return when (tag[1]) { + "white" -> Color.WHITE + "black" -> Color.BLACK + else -> null + } + } + + fun assemble(color: Color) = arrayOf(TAG_NAME, if (color == Color.WHITE) "white" else "black") + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/challenge/tags/TimeControlTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/challenge/tags/TimeControlTag.kt new file mode 100644 index 000000000..84f1c9494 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/challenge/tags/TimeControlTag.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess.challenge.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class TimeControlTag { + companion object { + const val TAG_NAME = "time_control" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): String? { + ensure(tag.has(1) && tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(timeControl: String) = arrayOf(TAG_NAME, timeControl) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/draw/LiveChessDrawOfferEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/draw/LiveChessDrawOfferEvent.kt new file mode 100644 index 000000000..ab7c05076 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/draw/LiveChessDrawOfferEvent.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess.draw + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Live Chess Draw Offer Event (Kind 30068) + * + * Offer a draw to opponent. Opponent can accept by sending a game end event + * with DRAW_AGREEMENT termination, or decline/ignore by making their next move. + * + * Tags: + * - d: game_id + * - p: opponent pubkey + * + * Content: Optional message + */ +@Immutable +class LiveChessDrawOfferEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1) + + fun message(): String = content + + companion object { + const val KIND = 30068 + const val ALT_DESCRIPTION = "Chess draw offer" + + fun build( + gameId: String, + opponentPubkey: String, + message: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, message, createdAt) { + add(arrayOf("d", gameId)) + add(arrayOf("p", opponentPubkey)) + alt(ALT_DESCRIPTION) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/LiveChessGameEndEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/LiveChessGameEndEvent.kt new file mode 100644 index 000000000..f7f2773ec --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/LiveChessGameEndEvent.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess.end + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip64Chess.GameResult +import com.vitorpamplona.quartz.nip64Chess.GameTermination +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Live Chess Game End Event (Kind 30067) + * + * Game result and termination + * + * Tags: + * - d: game_id + * - result: "1-0"|"0-1"|"1/2-1/2" + * - termination: reason for game end + * - winner: pubkey of winner (if applicable) + * - p: opponent pubkey + * + * Content: Optional PGN of complete game + */ +@Immutable +class LiveChessGameEndEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun result() = tags.result() + + fun termination() = tags.termination() + + fun winnerPubkey() = tags.winnerPubkey() + + fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1) + + fun pgn(): String = content + + companion object { + const val KIND = 30067 + const val ALT_DESCRIPTION = "Chess game ended" + + fun build( + gameId: String, + result: GameResult, + termination: GameTermination, + winnerPubkey: String? = null, + opponentPubkey: String, + pgn: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, pgn, createdAt) { + add(arrayOf("d", gameId)) + result(result) + termination(termination) + winnerPubkey?.let { winner(it) } + add(arrayOf("p", opponentPubkey)) + alt("$ALT_DESCRIPTION: ${result.notation}") + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/TagArrayBuilderExt.kt new file mode 100644 index 000000000..22da9eae7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/TagArrayBuilderExt.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess.end + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip64Chess.GameResult +import com.vitorpamplona.quartz.nip64Chess.GameTermination +import com.vitorpamplona.quartz.nip64Chess.end.tags.ResultTag +import com.vitorpamplona.quartz.nip64Chess.end.tags.TerminationTag +import com.vitorpamplona.quartz.nip64Chess.end.tags.WinnerTag + +fun TagArrayBuilder.result(result: GameResult) = addUnique(ResultTag.assemble(result)) + +fun TagArrayBuilder.termination(termination: GameTermination) = addUnique(TerminationTag.assemble(termination)) + +fun TagArrayBuilder.winner(winnerPubkey: HexKey) = addUnique(WinnerTag.assemble(winnerPubkey)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/TagArrayExt.kt new file mode 100644 index 000000000..55ea46743 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/TagArrayExt.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess.end + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip64Chess.end.tags.ResultTag +import com.vitorpamplona.quartz.nip64Chess.end.tags.TerminationTag +import com.vitorpamplona.quartz.nip64Chess.end.tags.WinnerTag + +fun TagArray.result() = firstNotNullOfOrNull(ResultTag::parse) + +fun TagArray.termination() = firstNotNullOfOrNull(TerminationTag::parse) + +fun TagArray.winnerPubkey() = firstNotNullOfOrNull(WinnerTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/tags/ResultTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/tags/ResultTag.kt new file mode 100644 index 000000000..769246b08 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/tags/ResultTag.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess.end.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip64Chess.GameResult +import com.vitorpamplona.quartz.utils.ensure + +class ResultTag { + companion object { + const val TAG_NAME = "result" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): String? { + ensure(tag.has(1) && tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(result: GameResult) = arrayOf(TAG_NAME, result.notation) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/tags/TerminationTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/tags/TerminationTag.kt new file mode 100644 index 000000000..9a22a5292 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/tags/TerminationTag.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess.end.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip64Chess.GameTermination +import com.vitorpamplona.quartz.utils.ensure + +class TerminationTag { + companion object { + const val TAG_NAME = "termination" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): String? { + ensure(tag.has(1) && tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(termination: GameTermination) = arrayOf(TAG_NAME, termination.name.lowercase()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/tags/WinnerTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/tags/WinnerTag.kt new file mode 100644 index 000000000..c260a465b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/tags/WinnerTag.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess.end.tags + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class WinnerTag { + companion object { + const val TAG_NAME = "winner" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): HexKey? { + ensure(tag.has(1) && tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(winnerPubkey: HexKey) = arrayOf(TAG_NAME, winnerPubkey) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessGameEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/game/ChessGameEvent.kt similarity index 98% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessGameEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/game/ChessGameEvent.kt index 664c60b0b..f0d7f00e7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessGameEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/game/ChessGameEvent.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip64Chess +package com.vitorpamplona.quartz.nip64Chess.game import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event @@ -50,6 +50,16 @@ class ChessGameEvent( content: String, // PGN database format sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + /** + * Get PGN content from event + */ + fun pgn(): String = content + + /** + * Get alt text for non-supporting clients (NIP-31) + */ + fun altText(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "alt" }?.get(1) + companion object { const val KIND = 64 const val ALT_DESCRIPTION = "Chess Game" @@ -73,14 +83,4 @@ class ChessGameEvent( initializer() } } - - /** - * Get PGN content from event - */ - fun pgn(): String = content - - /** - * Get alt text for non-supporting clients (NIP-31) - */ - fun altText(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "alt" }?.get(1) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/jester/JesterContent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/jester/JesterContent.kt new file mode 100644 index 000000000..0eb536cd3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/jester/JesterContent.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess.jester + +import kotlinx.serialization.Serializable + +/** + * JSON content structure for Jester events + */ +@Serializable +data class JesterContent( + val version: String = "0", + val kind: Int, + val fen: String = JesterProtocol.FEN_START, + val move: String? = null, + val history: List = emptyList(), + val nonce: String? = null, + // Extended fields for Amethyst (backward compatible - jesterui ignores unknown fields) + val playerColor: String? = null, // "white" or "black" - challenger's color choice + val result: String? = null, // "1-0", "0-1", "1/2-1/2" for game end + val termination: String? = null, // "checkmate", "resignation", "draw_agreement", etc. +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/JesterEvents.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/jester/JesterEvent.kt similarity index 80% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/JesterEvents.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/jester/JesterEvent.kt index f650b2194..6b9075837 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/JesterEvents.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/jester/JesterEvent.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip64Chess +package com.vitorpamplona.quartz.nip64Chess.jester import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event @@ -26,65 +26,12 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip64Chess.Color +import com.vitorpamplona.quartz.nip64Chess.GameResult +import com.vitorpamplona.quartz.nip64Chess.GameTermination import com.vitorpamplona.quartz.utils.TimeUtils -import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json -/** - * Jester Protocol Implementation - * - * Compatible with jesterui (https://github.com/jesterui/jesterui) - * - * Key differences from previous implementation: - * - Single event kind (30) for all chess messages - * - Content is JSON with: version, kind, fen, move, history, nonce - * - Event linking via e-tags: [startId] or [startId, headId] - * - Full move history included in every move event - * - * Content kind values: - * - 0: Game start (challenge) - * - 1: Move - * - 2: Chat (not implemented) - * - * Reference: https://github.com/jesterui/jesterui/blob/devel/FLOW.md - */ -object JesterProtocol { - /** Jester uses kind 30 for all chess events */ - const val KIND = 30 - - /** SHA256 of starting FEN position - used as reference for game discovery */ - const val START_POSITION_HASH = "b1791d7fc9ae3d38966568c257ffb3a02cbf8394cdb4805bc70f64fc3c0b6879" - - /** Standard starting FEN */ - const val FEN_START = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" - - /** Content kind for game start */ - const val CONTENT_KIND_START = 0 - - /** Content kind for move */ - const val CONTENT_KIND_MOVE = 1 - - /** Content kind for chat */ - const val CONTENT_KIND_CHAT = 2 -} - -/** - * JSON content structure for Jester events - */ -@Serializable -data class JesterContent( - val version: String = "0", - val kind: Int, - val fen: String = JesterProtocol.FEN_START, - val move: String? = null, - val history: List = emptyList(), - val nonce: String? = null, - // Extended fields for Amethyst (backward compatible - jesterui ignores unknown fields) - val playerColor: String? = null, // "white" or "black" - challenger's color choice - val result: String? = null, // "1-0", "0-1", "1/2-1/2" for game end - val termination: String? = null, // "checkmate", "resignation", "draw_agreement", etc. -) - private val json = Json { ignoreUnknownKeys = true @@ -352,30 +299,3 @@ fun Event.toJesterEvent(): JesterEvent? { if (kind != JesterProtocol.KIND) return null return JesterEvent(id, pubKey, createdAt, tags, content, sig) } - -/** - * Game events container for Jester protocol - */ -data class JesterGameEvents( - val startEvent: JesterEvent?, - val moves: List, -) { - companion object { - fun empty() = JesterGameEvents(null, emptyList()) - } - - /** Get the latest move (with longest history) */ - fun latestMove(): JesterEvent? = moves.maxByOrNull { it.history().size } - - /** Get the current FEN position */ - fun currentFen(): String = latestMove()?.fen() ?: startEvent?.fen() ?: JesterProtocol.FEN_START - - /** Get the complete move history */ - fun fullHistory(): List = latestMove()?.history() ?: emptyList() - - /** Check if game has ended */ - fun isEnded(): Boolean = latestMove()?.result() != null - - /** Get the game result if ended */ - fun result(): String? = latestMove()?.result() -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/jester/JesterGameEvents.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/jester/JesterGameEvents.kt new file mode 100644 index 000000000..039805742 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/jester/JesterGameEvents.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess.jester + +/** + * Game events container for Jester protocol + */ +data class JesterGameEvents( + val startEvent: JesterEvent?, + val moves: List, +) { + companion object { + fun empty() = JesterGameEvents(null, emptyList()) + } + + /** Get the latest move (with longest history) */ + fun latestMove(): JesterEvent? = moves.maxByOrNull { it.history().size } + + /** Get the current FEN position */ + fun currentFen(): String = latestMove()?.fen() ?: startEvent?.fen() ?: JesterProtocol.FEN_START + + /** Get the complete move history */ + fun fullHistory(): List = latestMove()?.history() ?: emptyList() + + /** Check if game has ended */ + fun isEnded(): Boolean = latestMove()?.result() != null + + /** Get the game result if ended */ + fun result(): String? = latestMove()?.result() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/jester/JesterProtocol.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/jester/JesterProtocol.kt new file mode 100644 index 000000000..0ecf6df8d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/jester/JesterProtocol.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess.jester + +/** + * Jester Protocol Constants + * + * Compatible with jesterui (https://github.com/jesterui/jesterui) + * + * Key differences from previous implementation: + * - Single event kind (30) for all chess messages + * - Content is JSON with: version, kind, fen, move, history, nonce + * - Event linking via e-tags: [startId] or [startId, headId] + * - Full move history included in every move event + * + * Content kind values: + * - 0: Game start (challenge) + * - 1: Move + * - 2: Chat (not implemented) + * + * Reference: https://github.com/jesterui/jesterui/blob/devel/FLOW.md + */ +object JesterProtocol { + /** Jester uses kind 30 for all chess events */ + const val KIND = 30 + + /** SHA256 of starting FEN position - used as reference for game discovery */ + const val START_POSITION_HASH = "b1791d7fc9ae3d38966568c257ffb3a02cbf8394cdb4805bc70f64fc3c0b6879" + + /** Standard starting FEN */ + const val FEN_START = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" + + /** Content kind for game start */ + const val CONTENT_KIND_START = 0 + + /** Content kind for move */ + const val CONTENT_KIND_MOVE = 1 + + /** Content kind for chat */ + const val CONTENT_KIND_CHAT = 2 +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/move/LiveChessMoveEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/move/LiveChessMoveEvent.kt new file mode 100644 index 000000000..869930687 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/move/LiveChessMoveEvent.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess.move + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Live Chess Move Event (Kind 30066) + * + * Individual move in a live chess game + * + * Tags: + * - d: game_id + * - move_number: move number (1-based) + * - san: move in Standard Algebraic Notation + * - fen: resulting position in FEN notation + * - p: opponent pubkey + * + * Content: Optional move comment + */ +@Immutable +class LiveChessMoveEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun gameId() = tags.gameId() + + fun moveNumber() = tags.moveNumber() + + fun san() = tags.san() + + fun fen() = tags.fen() + + fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1) + + fun comment(): String = content + + companion object { + const val KIND = 30066 + const val ALT_DESCRIPTION = "Chess move" + + fun build( + gameId: String, + moveNumber: Int, + san: String, + fen: String, + opponentPubkey: String, + comment: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, comment, createdAt) { + add(arrayOf("d", "$gameId-$moveNumber")) + gameId(gameId) + moveNumber(moveNumber) + san(san) + fen(fen) + add(arrayOf("p", opponentPubkey)) + alt("$ALT_DESCRIPTION: $san") + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/move/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/move/TagArrayBuilderExt.kt new file mode 100644 index 000000000..0a6a0d33c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/move/TagArrayBuilderExt.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess.move + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip64Chess.move.tags.FenTag +import com.vitorpamplona.quartz.nip64Chess.move.tags.GameIdTag +import com.vitorpamplona.quartz.nip64Chess.move.tags.MoveNumberTag +import com.vitorpamplona.quartz.nip64Chess.move.tags.SanTag + +fun TagArrayBuilder.gameId(gameId: String) = addUnique(GameIdTag.assemble(gameId)) + +fun TagArrayBuilder.moveNumber(moveNumber: Int) = addUnique(MoveNumberTag.assemble(moveNumber)) + +fun TagArrayBuilder.san(san: String) = addUnique(SanTag.assemble(san)) + +fun TagArrayBuilder.fen(fen: String) = addUnique(FenTag.assemble(fen)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/move/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/move/TagArrayExt.kt new file mode 100644 index 000000000..9651d9a85 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/move/TagArrayExt.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess.move + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip64Chess.move.tags.FenTag +import com.vitorpamplona.quartz.nip64Chess.move.tags.GameIdTag +import com.vitorpamplona.quartz.nip64Chess.move.tags.MoveNumberTag +import com.vitorpamplona.quartz.nip64Chess.move.tags.SanTag + +fun TagArray.gameId() = firstNotNullOfOrNull(GameIdTag::parse) + +fun TagArray.moveNumber() = firstNotNullOfOrNull(MoveNumberTag::parse) + +fun TagArray.san() = firstNotNullOfOrNull(SanTag::parse) + +fun TagArray.fen() = firstNotNullOfOrNull(FenTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/move/tags/FenTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/move/tags/FenTag.kt new file mode 100644 index 000000000..bda7f867e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/move/tags/FenTag.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess.move.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class FenTag { + companion object { + const val TAG_NAME = "fen" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): String? { + ensure(tag.has(1) && tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(fen: String) = arrayOf(TAG_NAME, fen) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/move/tags/GameIdTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/move/tags/GameIdTag.kt new file mode 100644 index 000000000..4f16bb095 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/move/tags/GameIdTag.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess.move.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class GameIdTag { + companion object { + const val TAG_NAME = "game_id" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): String? { + ensure(tag.has(1) && tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(gameId: String) = arrayOf(TAG_NAME, gameId) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/move/tags/MoveNumberTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/move/tags/MoveNumberTag.kt new file mode 100644 index 000000000..6978c5b3d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/move/tags/MoveNumberTag.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess.move.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class MoveNumberTag { + companion object { + const val TAG_NAME = "move_number" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): Int? { + ensure(tag.has(1) && tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].toIntOrNull() + } + + fun assemble(moveNumber: Int) = arrayOf(TAG_NAME, moveNumber.toString()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/move/tags/SanTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/move/tags/SanTag.kt new file mode 100644 index 000000000..6d3897676 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/move/tags/SanTag.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess.move.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class SanTag { + companion object { + const val TAG_NAME = "san" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): String? { + ensure(tag.has(1) && tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(san: String) = arrayOf(TAG_NAME, san) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 9cb287dc6..a3ace34f8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -114,13 +114,13 @@ import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent -import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent -import com.vitorpamplona.quartz.nip64Chess.JesterEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessDrawOfferEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent +import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent +import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent +import com.vitorpamplona.quartz.nip64Chess.draw.LiveChessDrawOfferEvent +import com.vitorpamplona.quartz.nip64Chess.accept.LiveChessGameAcceptEvent +import com.vitorpamplona.quartz.nip64Chess.challenge.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent +import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.RelayMonitorEvent From 73b8059b5559a8b64f1c0ed856e14fe6b6cb0dc6 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Wed, 4 Mar 2026 09:45:01 +0200 Subject: [PATCH 10/13] feat(desktop): collapsible info panel in chess deck column Hide GameInfo/MoveHistory/Actions panel by default in deck view with a chevron toggle to expand. Add Chess to AddColumnDialog. Co-Authored-By: Claude Opus 4.6 --- .../amethyst/desktop/chess/ChessScreen.kt | 368 ++++++++++-------- .../desktop/ui/deck/AddColumnDialog.kt | 1 + .../desktop/ui/deck/DeckColumnContainer.kt | 1 + 3 files changed, 198 insertions(+), 172 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt index 48f0d794f..432fd7c47 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt @@ -39,6 +39,8 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Refresh @@ -90,6 +92,7 @@ fun ChessScreen( relayManager: DesktopRelayConnectionManager, account: AccountState.LoggedIn, onBack: () -> Unit = {}, + compactMode: Boolean = false, ) { val scope = rememberCoroutineScope() val viewModel = @@ -274,6 +277,7 @@ fun ChessScreen( }, onResign = { viewModel.resign(gameState.startEventId) }, isSpectatorOverride = isSpectating, + compactMode = compactMode, ) } } else { @@ -593,6 +597,7 @@ private fun DesktopChessGameLayout( onMoveMade: (from: String, to: String, san: String) -> Unit, onResign: () -> Unit, isSpectatorOverride: Boolean? = null, + compactMode: Boolean = false, ) { // Collect state flows to trigger recomposition on changes val currentPosition by gameState.currentPosition.collectAsState() @@ -604,12 +609,13 @@ private fun DesktopChessGameLayout( val opponentPubkey = gameState.opponentPubkey val isSpectator = isSpectatorOverride ?: gameState.isSpectator + var showInfoPanel by remember { mutableStateOf(!compactMode) } + BoxWithConstraints( modifier = Modifier.fillMaxSize().padding(16.dp), ) { // Calculate board size based on available space - // Leave room for the info panel (300dp + 24dp spacing) - val infoPanelWidth = 300.dp + 24.dp + val infoPanelWidth = if (showInfoPanel) 300.dp + 24.dp else 0.dp val availableWidth = maxWidth - infoPanelWidth val availableHeight = maxHeight // Board should fit within available space, maintaining square aspect ratio @@ -617,11 +623,11 @@ private fun DesktopChessGameLayout( Row( modifier = Modifier.fillMaxSize(), - horizontalArrangement = Arrangement.spacedBy(24.dp), + horizontalArrangement = Arrangement.spacedBy(if (showInfoPanel) 24.dp else 0.dp), ) { - // Left side: Chess board + // Left side: Chess board + toggle Box( - modifier = Modifier.fillMaxHeight(), + modifier = Modifier.weight(1f).fillMaxHeight(), contentAlignment = Alignment.Center, ) { InteractiveChessBoard( @@ -633,188 +639,206 @@ private fun DesktopChessGameLayout( positionVersion = moveHistory.size, onMoveMade = onMoveMade, ) + + // Toggle button anchored to top-end of board area + IconButton( + onClick = { showInfoPanel = !showInfoPanel }, + modifier = Modifier.align(Alignment.TopEnd), + ) { + Icon( + if (showInfoPanel) { + Icons.AutoMirrored.Filled.KeyboardArrowRight + } else { + Icons.AutoMirrored.Filled.KeyboardArrowLeft + }, + contentDescription = if (showInfoPanel) "Hide info panel" else "Show info panel", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } - // Right side: Game info, moves, controls - Column( - modifier = - Modifier - .width(300.dp) - .fillMaxHeight() - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - // Extract human-readable game name - val gameName = - remember(startEventId) { - ChessGameNameGenerator.extractDisplayName(startEventId) - } - - // Game info card - Card( - modifier = Modifier.fillMaxWidth(), + // Right side: Game info, moves, controls (collapsible) + if (showInfoPanel) { + Column( + modifier = + Modifier + .width(300.dp) + .fillMaxHeight() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - // Show readable game name if available - if (gameName != null) { - Text( - gameName, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, - ) - } else { - Text( - "Game Info", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) + // Extract human-readable game name + val gameName = + remember(startEventId) { + ChessGameNameGenerator.extractDisplayName(startEventId) } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), + // Game info card + Card( + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - UserAvatar( - userHex = opponentPubkey, - pictureUrl = opponentPicture, - size = 48.dp, - ) - Column { - if (isSpectator) { - Text( - "Spectating", - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.tertiary, - ) - } else { - Text( - "vs $opponentName", - style = MaterialTheme.typography.bodyLarge, - ) - Text( - "You play ${if (playerColor == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + // Show readable game name if available + if (gameName != null) { + Text( + gameName, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + } else { + Text( + "Game Info", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + UserAvatar( + userHex = opponentPubkey, + pictureUrl = opponentPicture, + size = 48.dp, + ) + Column { + if (isSpectator) { + Text( + "Spectating", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.tertiary, + ) + } else { + Text( + "vs $opponentName", + style = MaterialTheme.typography.bodyLarge, + ) + Text( + "You play ${if (playerColor == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + // Use currentPosition to derive turn (triggers recomposition on move) + val currentTurn = currentPosition.activeColor + + if (isSpectator) { + Text( + "${if (currentTurn == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}'s turn", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + val isYourTurn = currentTurn == playerColor + Text( + if (isYourTurn) "Your turn" else "Opponent's turn", + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (isYourTurn) FontWeight.Bold else FontWeight.Normal, + color = + if (isYourTurn) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + } + + // Move history card + if (moveHistory.isNotEmpty()) { + Card( + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + "Move History", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + + // Format moves as numbered pairs + val moveText = + moveHistory + .chunked(2) + .mapIndexed { index, pair -> + "${index + 1}. ${pair.joinToString(" ")}" + }.joinToString("\n") + + Text( + moveText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + // Game controls card (only for participants, not spectators) + if (!isSpectator) { + Card( + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + "Actions", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + + OutlinedButton( + onClick = onResign, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Resign") } } } - - // Use currentPosition to derive turn (triggers recomposition on move) - val currentTurn = currentPosition.activeColor - - if (isSpectator) { - Text( - "${if (currentTurn == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}'s turn", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } else { - val isYourTurn = currentTurn == playerColor - Text( - if (isYourTurn) "Your turn" else "Opponent's turn", - style = MaterialTheme.typography.bodyMedium, - fontWeight = if (isYourTurn) FontWeight.Bold else FontWeight.Normal, - color = - if (isYourTurn) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - } - } - } - - // Move history card - if (moveHistory.isNotEmpty()) { - Card( - modifier = Modifier.fillMaxWidth(), - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + } else { + // Spectator info card + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f), + ), ) { - Text( - "Move History", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - - // Format moves as numbered pairs - val moveText = - moveHistory - .chunked(2) - .mapIndexed { index, pair -> - "${index + 1}. ${pair.joinToString(" ")}" - }.joinToString("\n") - - Text( - moveText, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - - // Game controls card (only for participants, not spectators) - if (!isSpectator) { - Card( - modifier = Modifier.fillMaxWidth(), - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - "Actions", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - - OutlinedButton( - onClick = onResign, - modifier = Modifier.fillMaxWidth(), + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Text("Resign") + Icon(Icons.Default.Visibility, contentDescription = null) + Text( + "Watching game - spectator mode", + style = MaterialTheme.typography.bodyMedium, + ) } } } - } else { - // Spectator info card - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f), - ), - ) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Icon(Icons.Default.Visibility, contentDescription = null) - Text( - "Watching game - spectator mode", - style = MaterialTheme.typography.bodyMedium, - ) - } - } - } - // Game ID (small footer) - Text( - "Game: ${startEventId.take(16)}...", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + // Game ID (small footer) + Text( + "Game: ${startEventId.take(16)}...", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt index 3c4f6628a..f7ef606dd 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt @@ -56,6 +56,7 @@ private val COLUMN_OPTIONS = DeckColumnType.Bookmarks, DeckColumnType.GlobalFeed, DeckColumnType.MyProfile, + DeckColumnType.Chess, ) @Composable diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index 9751cbf82..34d324564 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -276,6 +276,7 @@ internal fun RootContent( relayManager = relayManager, account = account, onBack = {}, + compactMode = true, ) } From db530a6820728a95954a6639dcdaece060feb370 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Wed, 4 Mar 2026 10:02:22 +0200 Subject: [PATCH 11/13] fix(desktop): audit fixes for deck layout Critical: - GlobalFeed now renders global feed (was identical to HomeFeed) - DeckState uses atomic _columns.update{} instead of non-atomic .value= - lastKnownWidth marked @Volatile for thread safety Medium: - save() debounced 500ms via coroutine (was sync disk I/O per drag frame) - Width redistribution fills gaps after clamping on column remove - Hashtag column pre-fills search query - ColumnNavigationState.stack exposed as StateFlow not MutableStateFlow - Settings column deduped (focuses existing instead of adding duplicate) - Exceptions logged in save/load instead of silently swallowed Low: - Thread column icon fixed (was Home, now Article) - Drag divider hit target widened to 12dp (visual stays 4dp) - Horizontal scroll added for column overflow - Auto-fit columns on first composition / window resize - Column shortcuts limited to actual column count - Deterministic default column IDs - Overlay fallback shows message instead of blank Co-Authored-By: Claude Opus 4.6 --- .../vitorpamplona/amethyst/desktop/Main.kt | 22 ++- .../amethyst/desktop/ui/FeedScreen.kt | 3 +- .../amethyst/desktop/ui/SearchScreen.kt | 8 + .../amethyst/desktop/ui/deck/ColumnHeader.kt | 2 +- .../desktop/ui/deck/DeckColumnContainer.kt | 15 +- .../amethyst/desktop/ui/deck/DeckLayout.kt | 23 ++- .../amethyst/desktop/ui/deck/DeckState.kt | 182 +++++++++++------- 7 files changed, 177 insertions(+), 78 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index ef02ca6f3..fa3abb3e2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -141,7 +141,8 @@ fun main() = ) var showComposeDialog by remember { mutableStateOf(false) } var replyToNote by remember { mutableStateOf(null) } - val deckState = remember { DeckState().also { it.load() } } + val deckScope = rememberCoroutineScope() + val deckState = remember { DeckState(deckScope).also { it.load() } } var showAddColumnDialog by remember { mutableStateOf(false) } var layoutMode by remember { mutableStateOf( @@ -179,7 +180,13 @@ fun main() = } else { KeyShortcut(Key.Comma, ctrl = true) }, - onClick = { deckState.addColumn(DeckColumnType.Settings) }, + onClick = { + if (deckState.hasColumnOfType(DeckColumnType.Settings)) { + deckState.focusExistingColumn(DeckColumnType.Settings) + } else { + deckState.addColumn(DeckColumnType.Settings) + } + }, ) Separator() Item( @@ -305,7 +312,8 @@ fun main() = Key.Eight, Key.Nine, ) - columnKeys.forEachIndexed { i, key -> + val columnCount = deckState.columns.value.size + columnKeys.take(columnCount).forEachIndexed { i, key -> Item( "Column ${i + 1}", shortcut = @@ -617,7 +625,13 @@ fun MainContent( LayoutMode.DECK -> { DeckSidebar( onAddColumn = onShowAddColumnDialog, - onOpenSettings = { deckState.addColumn(DeckColumnType.Settings) }, + onOpenSettings = { + if (deckState.hasColumnOfType(DeckColumnType.Settings)) { + deckState.focusExistingColumn(DeckColumnType.Settings) + } else { + deckState.addColumn(DeckColumnType.Settings) + } + }, ) VerticalDivider() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 0e04f34dd..e79aa3cfa 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -151,6 +151,7 @@ fun FeedScreen( account: AccountState.LoggedIn? = null, nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, + initialFeedMode: FeedMode? = null, onCompose: () -> Unit = {}, onNavigateToProfile: (String) -> Unit = {}, onNavigateToThread: (String) -> Unit = {}, @@ -170,7 +171,7 @@ fun FeedScreen( } val events by eventState.items.collectAsState() var replyToEvent by remember { mutableStateOf(null) } - var feedMode by remember { mutableStateOf(DesktopPreferences.feedMode) } + var feedMode by remember { mutableStateOf(initialFeedMode ?: DesktopPreferences.feedMode) } var followedUsers by remember { mutableStateOf>(emptySet()) } var zapsByEvent by remember { mutableStateOf>>(emptyMap()) } // Track reaction event IDs per target event to deduplicate diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt index c787c3471..cc4cee93b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt @@ -78,6 +78,7 @@ fun SearchScreen( localCache: DesktopLocalCache, relayManager: DesktopRelayConnectionManager, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, + initialQuery: String = "", onNavigateToProfile: (String) -> Unit, onNavigateToThread: (String) -> Unit, onNavigateToHashtag: (String) -> Unit = {}, @@ -86,6 +87,13 @@ fun SearchScreen( val scope = rememberCoroutineScope() val searchState = remember { SearchBarState(localCache, scope) } val focusRequester = remember { FocusRequester() } + + // Pre-fill initial query (e.g., hashtag column) + LaunchedEffect(initialQuery) { + if (initialQuery.isNotBlank()) { + searchState.updateSearchText(initialQuery) + } + } val relayStatuses by relayManager.relayStatuses.collectAsState() // Collect state from SearchBarState diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt index da4933ad8..3deb62b25 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt @@ -133,6 +133,6 @@ fun DeckColumnType.icon(): ImageVector = DeckColumnType.Chess -> Icons.Default.Extension DeckColumnType.Settings -> Icons.Default.Settings is DeckColumnType.Profile -> Icons.Default.Person - is DeckColumnType.Thread -> Icons.Default.Home + is DeckColumnType.Thread -> Icons.AutoMirrored.Filled.Article is DeckColumnType.Hashtag -> Icons.Default.Tag } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index 34d324564..ce90b7ef6 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.desktop.chess.ChessScreen import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator +import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode import com.vitorpamplona.amethyst.desktop.ui.BookmarksScreen import com.vitorpamplona.amethyst.desktop.ui.FeedScreen import com.vitorpamplona.amethyst.desktop.ui.NotificationsScreen @@ -56,10 +57,11 @@ import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow class ColumnNavigationState { private val _stack = MutableStateFlow>(emptyList()) - val stack = _stack + val stack: kotlinx.coroutines.flow.StateFlow> = _stack.asStateFlow() fun push(screen: DesktopScreen) { _stack.value = _stack.value + screen @@ -180,6 +182,7 @@ internal fun RootContent( account = account, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, + initialFeedMode = FeedMode.FOLLOWING, onCompose = onShowComposeDialog, onNavigateToProfile = onNavigateToProfile, onNavigateToThread = onNavigateToThread, @@ -249,6 +252,7 @@ internal fun RootContent( account = account, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, + initialFeedMode = FeedMode.GLOBAL, onCompose = onShowComposeDialog, onNavigateToProfile = onNavigateToProfile, onNavigateToThread = onNavigateToThread, @@ -320,6 +324,7 @@ internal fun RootContent( localCache = localCache, relayManager = relayManager, subscriptionsCoordinator = subscriptionsCoordinator, + initialQuery = "#${columnType.tag}", onNavigateToProfile = onNavigateToProfile, onNavigateToThread = onNavigateToThread, ) @@ -374,6 +379,12 @@ internal fun OverlayContent( ) } - else -> {} + else -> { + androidx.compose.material3.Text( + "Unsupported screen type", + style = androidx.compose.material3.MaterialTheme.typography.bodyMedium, + color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt index 902b8871d..1df6bf135 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt @@ -21,15 +21,18 @@ package com.vitorpamplona.amethyst.desktop.ui.deck import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState import androidx.compose.material3.MaterialTheme import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier @@ -70,7 +73,22 @@ fun DeckLayout( val availableWidthDp = with(density) { constraints.maxWidth.toDp().value } deckState.setAvailableWidth(availableWidthDp) - Row(modifier = Modifier.fillMaxSize()) { + // Auto-fit columns on first composition or when available width changes significantly + LaunchedEffect(availableWidthDp, columns.size) { + val dividers = (columns.size - 1) * DeckState.DIVIDER_WIDTH + val totalColumnWidth = columns.sumOf { it.width.toDouble() }.toFloat() + val diff = kotlin.math.abs(totalColumnWidth + dividers - availableWidthDp) + if (diff > 20f && columns.isNotEmpty()) { + deckState.fitColumnsToWidth(availableWidthDp) + } + } + + Row( + modifier = + Modifier + .fillMaxSize() + .horizontalScroll(rememberScrollState()), + ) { columns.forEachIndexed { index, column -> if (index > 0) { DraggableDivider( @@ -108,7 +126,7 @@ private fun DraggableDivider(onDrag: (Float) -> Unit) { Box( modifier = Modifier - .width(4.dp) + .width(12.dp) .fillMaxHeight() .pointerHoverIcon(PointerIcon(Cursor(Cursor.E_RESIZE_CURSOR))) .pointerInput(Unit) { @@ -117,6 +135,7 @@ private fun DraggableDivider(onDrag: (Float) -> Unit) { onDrag(dragAmount.x / density) } }, + contentAlignment = androidx.compose.ui.Alignment.Center, ) { VerticalDivider( color = MaterialTheme.colorScheme.outlineVariant, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt index 54ec6c1ec..bdfb40298 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt @@ -23,19 +23,29 @@ package com.vitorpamplona.amethyst.desktop.ui.deck import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue import com.vitorpamplona.amethyst.desktop.DesktopPreferences +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch -class DeckState { +class DeckState( + private val saveScope: CoroutineScope, +) { private val _columns = MutableStateFlow(DEFAULT_COLUMNS) val columns: StateFlow> = _columns.asStateFlow() private val _focusedColumnIndex = MutableStateFlow(0) val focusedColumnIndex: StateFlow = _focusedColumnIndex.asStateFlow() + @Volatile private var lastKnownWidth: Float = 0f + private var saveJob: Job? = null + fun setAvailableWidth(width: Float) { lastKnownWidth = width } @@ -45,71 +55,95 @@ class DeckState { afterIndex: Int? = null, ) { val col = DeckColumn(type = type) - _columns.value = - if (afterIndex != null && afterIndex < _columns.value.size) { - _columns.value.toMutableList().apply { add(afterIndex + 1, col) } + _columns.update { current -> + if (afterIndex != null && afterIndex < current.size) { + current.toMutableList().apply { add(afterIndex + 1, col) } } else { - _columns.value + col + current + col } + } // Auto-fit all columns to available width when known - if (lastKnownWidth > 0f) { - fitColumnsToWidth(lastKnownWidth) + val width = lastKnownWidth + if (width > 0f) { + fitColumnsToWidth(width) } else { - save() + scheduleSave() } } + fun hasColumnOfType(type: DeckColumnType): Boolean = _columns.value.any { it.type == type } + + fun focusExistingColumn(type: DeckColumnType) { + val idx = _columns.value.indexOfFirst { it.type == type } + if (idx >= 0) focusColumn(idx) + } + fun removeColumn(id: String) { - if (_columns.value.size <= 1) return - val removed = _columns.value.find { it.id == id } ?: return - val remaining = _columns.value.filter { it.id != id } - // Redistribute removed column's width evenly across remaining columns - val extra = removed.width / remaining.size - _columns.value = - remaining.map { - it.copy(width = (it.width + extra).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)) + _columns.update { current -> + if (current.size <= 1) return + val removed = current.find { it.id == id } ?: return + val remaining = current.filter { it.id != id } + val extra = removed.width / remaining.size + val result = + remaining.map { + it.copy(width = (it.width + extra).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)) + } + // Fix gaps: if total is less than available, redistribute remainder + val width = lastKnownWidth + if (width > 0f) { + val dividers = (result.size - 1) * DIVIDER_WIDTH + val totalUsed = result.sumOf { it.width.toDouble() }.toFloat() + val deficit = width - dividers - totalUsed + if (deficit > 1f) { + val perColumn = deficit / result.size + return@update result.map { + it.copy(width = (it.width + perColumn).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)) + } + } } - if (_focusedColumnIndex.value >= _columns.value.size) { - _focusedColumnIndex.value = _columns.value.size - 1 + result } - save() + _focusedColumnIndex.update { idx -> + idx.coerceAtMost(_columns.value.size - 1) + } + scheduleSave() } fun moveColumn( fromIndex: Int, toIndex: Int, ) { - val list = _columns.value.toMutableList() - if (fromIndex !in list.indices || toIndex !in list.indices) return - val item = list.removeAt(fromIndex) - list.add(toIndex, item) - _columns.value = list - save() + _columns.update { current -> + if (fromIndex !in current.indices || toIndex !in current.indices) return + current.toMutableList().apply { + val item = removeAt(fromIndex) + add(toIndex, item) + } + } + scheduleSave() } fun updateColumnWidth( id: String, width: Float, ) { - _columns.value = - _columns.value.map { + _columns.update { current -> + current.map { if (it.id == id) it.copy(width = width.coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)) else it } - save() + } + scheduleSave() } fun expandColumn( id: String, availableWidth: Float, ) { - val cols = _columns.value - val target = cols.find { it.id == id } ?: return - val others = cols.filter { it.id != id } - // Shrink others to minimum, give the rest to the target - val othersMin = others.size * MIN_COLUMN_WIDTH - val dividerWidth = (cols.size - 1) * DIVIDER_WIDTH - val maxForTarget = (availableWidth - othersMin - dividerWidth).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH) - _columns.value = + _columns.update { cols -> + if (cols.find { it.id == id } == null) return + val othersMin = (cols.size - 1) * MIN_COLUMN_WIDTH + val dividerWidth = (cols.size - 1) * DIVIDER_WIDTH + val maxForTarget = (availableWidth - othersMin - dividerWidth).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH) cols.map { if (it.id == id) { it.copy(width = maxForTarget) @@ -117,7 +151,8 @@ class DeckState { it.copy(width = MIN_COLUMN_WIDTH) } } - save() + } + scheduleSave() } fun resizePair( @@ -126,24 +161,21 @@ class DeckState { delta: Float, availableWidth: Float, ) { - val cols = _columns.value - val left = cols.find { it.id == leftId } ?: return - val right = cols.find { it.id == rightId } ?: return - // Compute max total allowed (available minus other columns and dividers) - val otherWidth = cols.filter { it.id != leftId && it.id != rightId }.sumOf { it.width.toDouble() }.toFloat() - val dividerWidth = (cols.size - 1) * DIVIDER_WIDTH - val maxPairWidth = availableWidth - otherWidth - dividerWidth - var newLeft = (left.width + delta).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH) - var newRight = (right.width - delta).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH) - // Ensure pair doesn't exceed available space - if (newLeft + newRight > maxPairWidth) { - if (delta > 0) { - newLeft = (maxPairWidth - newRight).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH) - } else { - newRight = (maxPairWidth - newLeft).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH) + _columns.update { cols -> + val left = cols.find { it.id == leftId } ?: return + val right = cols.find { it.id == rightId } ?: return + val otherWidth = cols.filter { it.id != leftId && it.id != rightId }.sumOf { it.width.toDouble() }.toFloat() + val dividerWidth = (cols.size - 1) * DIVIDER_WIDTH + val maxPairWidth = availableWidth - otherWidth - dividerWidth + var newLeft = (left.width + delta).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH) + var newRight = (right.width - delta).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH) + if (newLeft + newRight > maxPairWidth) { + if (delta > 0) { + newLeft = (maxPairWidth - newRight).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH) + } else { + newRight = (maxPairWidth - newLeft).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH) + } } - } - _columns.value = cols.map { when (it.id) { leftId -> it.copy(width = newLeft) @@ -151,17 +183,19 @@ class DeckState { else -> it } } - save() + } + scheduleSave() } fun fitColumnsToWidth(availableWidth: Float) { - val cols = _columns.value - if (cols.isEmpty()) return - val dividers = (cols.size - 1) * DIVIDER_WIDTH - val usable = availableWidth - dividers - val perColumn = (usable / cols.size).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH) - _columns.value = cols.map { it.copy(width = perColumn) } - save() + _columns.update { cols -> + if (cols.isEmpty()) return + val dividers = (cols.size - 1) * DIVIDER_WIDTH + val usable = availableWidth - dividers + val perColumn = (usable / cols.size).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH) + cols.map { it.copy(width = perColumn) } + } + scheduleSave() } fun focusColumn(index: Int) { @@ -170,6 +204,15 @@ class DeckState { } } + private fun scheduleSave() { + saveJob?.cancel() + saveJob = + saveScope.launch { + delay(SAVE_DEBOUNCE_MS) + save() + } + } + fun save() { try { val data = @@ -188,7 +231,8 @@ class DeckState { ) } DesktopPreferences.deckColumns = mapper.writeValueAsString(data) - } catch (_: Exception) { + } catch (e: Exception) { + println("DeckState: failed to save columns: ${e.message}") } } @@ -207,20 +251,22 @@ class DeckState { if (loaded.isNotEmpty()) { _columns.value = loaded } - } catch (_: Exception) { + } catch (e: Exception) { + println("DeckState: failed to load columns: ${e.message}") } } companion object { const val MIN_COLUMN_WIDTH = 300f const val MAX_COLUMN_WIDTH = 800f - const val DIVIDER_WIDTH = 4f + const val DIVIDER_WIDTH = 12f + private const val SAVE_DEBOUNCE_MS = 500L val DEFAULT_COLUMNS = listOf( - DeckColumn(type = DeckColumnType.HomeFeed), - DeckColumn(type = DeckColumnType.Notifications), - DeckColumn(type = DeckColumnType.Messages), + DeckColumn(id = "default-home", type = DeckColumnType.HomeFeed), + DeckColumn(id = "default-notifications", type = DeckColumnType.Notifications), + DeckColumn(id = "default-messages", type = DeckColumnType.Messages), ) private val mapper = jacksonObjectMapper() From a5fe5836e56fa87b2a6c1bf0adaff2a68f1aa0e8 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Wed, 4 Mar 2026 13:28:27 +0000 Subject: [PATCH 12/13] New Crowdin translations by GitHub Action --- amethyst/src/main/res/values-cs-rCZ/strings.xml | 6 ++++++ amethyst/src/main/res/values-de-rDE/strings.xml | 17 +++++++++++++++++ amethyst/src/main/res/values-pt-rBR/strings.xml | 13 +++++++++++++ amethyst/src/main/res/values-sv-rSE/strings.xml | 10 ++++++++++ 4 files changed, 46 insertions(+) diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 912d90571..4850ce01d 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -1398,11 +1398,15 @@ Prostor schůzky Profil Seznam ztlumených + NNS + NIP + Nostr Connect Stav DVM Požadavek na obsah DVM Odpověď obsahu DVM Požadavek uživatele DVM Odpověď uživatele DVM + OTS Platba na Seznamy lidí Obrázky @@ -1434,7 +1438,9 @@ Důvěryhodní poskytovatelé Video (odpověď) Krátká videa (odpověď) + Video Krátká videa Hlasová zpráva Hlasová odpověď + Wiki diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index ec219b17a..d1fa799fd 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -1321,6 +1321,7 @@ anz der Bedingungen ist erforderlich %1$s entfernen Outbox-Relays + Apps App-Empfehlungen Benutzereinstellungen Audio-Header @@ -1373,6 +1374,7 @@ anz der Bedingungen ist erforderlich Blob-Header Medizinische Daten Follow-Pakete + Reposts (16) Geohash-Follows Geschenkverpackungen Git-Issue @@ -1381,6 +1383,7 @@ anz der Bedingungen ist erforderlich Git-Antwort Zap-Ziele Gefolgte Hashtags + Highlights HTTP-Authentifizierung Index-Relay-Liste Abenteuer-Prolog @@ -1389,26 +1392,35 @@ anz der Bedingungen ist erforderlich Benannte Lesezeichen Live-Chats Live-Streams + Zap NWC-Anfrage NWC-Antwort + Privaten Zaps Zap-Anfrage + Blogs Besprechungsraum Raumpräsenz Besprechungsbereich Profil Stummschaltliste + NNS + NIP + Nostr Connect DVM-Status DVM-Inhaltsanfrage DVM-Inhaltsantwort DVM-Benutzeranfrage DVM-Benutzerantwort + OTS Zahlen an Personenlisten Bilder + Angepinnt Zap-Umfrage Umfrage Umfrageantwort NIP-04-DMs + Private Relais Proxy-Relays Öffentliche Nachricht Reaktionen @@ -1418,17 +1430,22 @@ anz der Bedingungen ist erforderlich Relay-Monitor-Ankündigung Relay-Set Meldungen + Reposts Benutzer löschen Siegel Such-Relays Benutzerstatus Notizen Bearbeitungen + Torrents Torrent-Kommentare Vertrauenswürdige Relays Vertrauenswürdige Anbieter Video (Antwort) Shorts (Antwort) + Video + Shorts Sprachnachricht Sprachantwort + Wiki diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 0853836c7..f58008471 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -796,6 +796,7 @@ Som ligado. Clique para silenciar Voltar %d segundos Avançar %d segundos + Picture-in-Picture Pesquise registros locais e remotos O endereço Nostr foi verificado Endereço Nostr falhou na verificação @@ -1315,6 +1316,7 @@ Remover %1$s Relays de Saída + Aplicativos Recomendações de Apps Configurações do Usuário Cabeçalho de Áudio @@ -1367,6 +1369,7 @@ Cabeçalhos Blob Dados Médicos Pacotes de Seguir + Repostagens (16) Seguindo Geohash Embrulhos de presente Issue do Git @@ -1384,20 +1387,26 @@ Favoritos nomeados Chats ao vivo Transmissões ao vivo + Zaps Solicitação NWC Resposta NWC Zaps privados Solicitação de Zap + Blogs Sala de reunião Presença na sala Espaço de reunião Perfil Lista de silenciados + NNS + CNPJ + Nostr Connect Status do DVM Solicitação de conteúdo DVM Resposta de conteúdo DVM Solicitação de usuário DVM Resposta de usuário DVM + OTS Pagar para Listas de pessoas Imagens @@ -1416,18 +1425,22 @@ Anúncio de Monitor de Relay Conjunto de relays Denúncias + Repostagens Excluir usuário Selos Relays de busca Status do usuário Notas Edições + Torrents Comentários de torrent Relays confiáveis Provedores confiáveis Vídeo (resposta) Shorts (resposta) Vídeo + Shorts Mensagem de voz Resposta de voz + Wiki diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 69cc53cbd..ec57be293 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -1368,6 +1368,7 @@ Blob-huvuden Medicinska data Följ-paket + Återinlägg (16) Geohash-följer Presentinslagningar Git-ärende @@ -1385,6 +1386,7 @@ Namngivna bokmärken Livechattar Livesändningar + Zaps NWC-förfrågan NWC-svar Privata zaps @@ -1395,11 +1397,15 @@ Mötesutrymme Profil Tystnadslista + NNS + NIP + Nostr Connect DVM-status DVM-innehållsförfrågan DVM-innehållssvar DVM-användarförfrågan DVM-användarsvar + OTS Betala till Personlistor Bilder @@ -1418,6 +1424,7 @@ Relay övervakningsmeddelande Reläuppsättning Rapporter + Återinlägg Radera användare Sigill Sökreläer @@ -1430,6 +1437,9 @@ Betrodda leverantörer Video (svar) Shorts (svar) + Video + Shorts Röstmeddelande Röstsvar + Wiki From af589afa4d87f0a23d941d710454d7f78e9ee5e5 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Wed, 4 Mar 2026 13:34:05 +0000 Subject: [PATCH 13/13] New Crowdin translations by GitHub Action --- amethyst/src/main/res/values-cs-rCZ/strings.xml | 6 ++++++ amethyst/src/main/res/values-de-rDE/strings.xml | 17 +++++++++++++++++ amethyst/src/main/res/values-pt-rBR/strings.xml | 13 +++++++++++++ amethyst/src/main/res/values-sv-rSE/strings.xml | 10 ++++++++++ 4 files changed, 46 insertions(+) diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 912d90571..4850ce01d 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -1398,11 +1398,15 @@ Prostor schůzky Profil Seznam ztlumených + NNS + NIP + Nostr Connect Stav DVM Požadavek na obsah DVM Odpověď obsahu DVM Požadavek uživatele DVM Odpověď uživatele DVM + OTS Platba na Seznamy lidí Obrázky @@ -1434,7 +1438,9 @@ Důvěryhodní poskytovatelé Video (odpověď) Krátká videa (odpověď) + Video Krátká videa Hlasová zpráva Hlasová odpověď + Wiki diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index ec219b17a..d1fa799fd 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -1321,6 +1321,7 @@ anz der Bedingungen ist erforderlich %1$s entfernen Outbox-Relays + Apps App-Empfehlungen Benutzereinstellungen Audio-Header @@ -1373,6 +1374,7 @@ anz der Bedingungen ist erforderlich Blob-Header Medizinische Daten Follow-Pakete + Reposts (16) Geohash-Follows Geschenkverpackungen Git-Issue @@ -1381,6 +1383,7 @@ anz der Bedingungen ist erforderlich Git-Antwort Zap-Ziele Gefolgte Hashtags + Highlights HTTP-Authentifizierung Index-Relay-Liste Abenteuer-Prolog @@ -1389,26 +1392,35 @@ anz der Bedingungen ist erforderlich Benannte Lesezeichen Live-Chats Live-Streams + Zap NWC-Anfrage NWC-Antwort + Privaten Zaps Zap-Anfrage + Blogs Besprechungsraum Raumpräsenz Besprechungsbereich Profil Stummschaltliste + NNS + NIP + Nostr Connect DVM-Status DVM-Inhaltsanfrage DVM-Inhaltsantwort DVM-Benutzeranfrage DVM-Benutzerantwort + OTS Zahlen an Personenlisten Bilder + Angepinnt Zap-Umfrage Umfrage Umfrageantwort NIP-04-DMs + Private Relais Proxy-Relays Öffentliche Nachricht Reaktionen @@ -1418,17 +1430,22 @@ anz der Bedingungen ist erforderlich Relay-Monitor-Ankündigung Relay-Set Meldungen + Reposts Benutzer löschen Siegel Such-Relays Benutzerstatus Notizen Bearbeitungen + Torrents Torrent-Kommentare Vertrauenswürdige Relays Vertrauenswürdige Anbieter Video (Antwort) Shorts (Antwort) + Video + Shorts Sprachnachricht Sprachantwort + Wiki diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 0853836c7..f58008471 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -796,6 +796,7 @@ Som ligado. Clique para silenciar Voltar %d segundos Avançar %d segundos + Picture-in-Picture Pesquise registros locais e remotos O endereço Nostr foi verificado Endereço Nostr falhou na verificação @@ -1315,6 +1316,7 @@ Remover %1$s Relays de Saída + Aplicativos Recomendações de Apps Configurações do Usuário Cabeçalho de Áudio @@ -1367,6 +1369,7 @@ Cabeçalhos Blob Dados Médicos Pacotes de Seguir + Repostagens (16) Seguindo Geohash Embrulhos de presente Issue do Git @@ -1384,20 +1387,26 @@ Favoritos nomeados Chats ao vivo Transmissões ao vivo + Zaps Solicitação NWC Resposta NWC Zaps privados Solicitação de Zap + Blogs Sala de reunião Presença na sala Espaço de reunião Perfil Lista de silenciados + NNS + CNPJ + Nostr Connect Status do DVM Solicitação de conteúdo DVM Resposta de conteúdo DVM Solicitação de usuário DVM Resposta de usuário DVM + OTS Pagar para Listas de pessoas Imagens @@ -1416,18 +1425,22 @@ Anúncio de Monitor de Relay Conjunto de relays Denúncias + Repostagens Excluir usuário Selos Relays de busca Status do usuário Notas Edições + Torrents Comentários de torrent Relays confiáveis Provedores confiáveis Vídeo (resposta) Shorts (resposta) Vídeo + Shorts Mensagem de voz Resposta de voz + Wiki diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 69cc53cbd..ec57be293 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -1368,6 +1368,7 @@ Blob-huvuden Medicinska data Följ-paket + Återinlägg (16) Geohash-följer Presentinslagningar Git-ärende @@ -1385,6 +1386,7 @@ Namngivna bokmärken Livechattar Livesändningar + Zaps NWC-förfrågan NWC-svar Privata zaps @@ -1395,11 +1397,15 @@ Mötesutrymme Profil Tystnadslista + NNS + NIP + Nostr Connect DVM-status DVM-innehållsförfrågan DVM-innehållssvar DVM-användarförfrågan DVM-användarsvar + OTS Betala till Personlistor Bilder @@ -1418,6 +1424,7 @@ Relay övervakningsmeddelande Reläuppsättning Rapporter + Återinlägg Radera användare Sigill Sökreläer @@ -1430,6 +1437,9 @@ Betrodda leverantörer Video (svar) Shorts (svar) + Video + Shorts Röstmeddelande Röstsvar + Wiki