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..86b7e9d13 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,8 @@ 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() { @@ -54,4 +56,16 @@ 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) + } + + 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 5614e4e4b..fa3abb3e2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -25,32 +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.Extension -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,23 +70,20 @@ import androidx.compose.ui.window.rememberWindowState 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.chess.ChessScreen 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.deck.SinglePaneLayout import com.vitorpamplona.amethyst.desktop.ui.profile.ProfileInfoCard import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect @@ -110,8 +95,13 @@ import kotlinx.coroutines.launch private val isMacOS = System.getProperty("os.name").lowercase().contains("mac") +enum class LayoutMode { + SINGLE_PANE, + DECK, +} + /** - * Desktop navigation state - extends AppScreen with dynamic destinations. + * Desktop navigation state — used for in-column navigation (drill-down). */ sealed class DesktopScreen { object Feed : DesktopScreen() @@ -151,7 +141,18 @@ fun main() = ) var showComposeDialog by remember { mutableStateOf(false) } var replyToNote by remember { mutableStateOf(null) } - var currentScreen by remember { mutableStateOf(DesktopScreen.Feed) } + val deckScope = rememberCoroutineScope() + val deckState = remember { DeckState(deckScope).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, @@ -179,7 +180,13 @@ fun main() = } else { KeyShortcut(Key.Comma, ctrl = true) }, - onClick = { currentScreen = DesktopScreen.Settings }, + onClick = { + if (deckState.hasColumnOfType(DeckColumnType.Settings)) { + deckState.focusExistingColumn(DeckColumnType.Settings) + } else { + deckState.addColumn(DeckColumnType.Settings) + } + }, ) Separator() Item( @@ -216,9 +223,121 @@ fun main() = ) } Menu("View") { - Item("Feed", onClick = { }) - Item("Messages", onClick = { }) - Item("Notifications", onClick = { }) + Item( + if (layoutMode == LayoutMode.DECK) "\u2713 Deck Layout" else "Deck Layout", + shortcut = + if (isMacOS) { + KeyShortcut(Key.D, meta = true, shift = true) + } else { + KeyShortcut(Key.D, ctrl = true, shift = true) + }, + onClick = { + layoutMode = + if (layoutMode == LayoutMode.DECK) LayoutMode.SINGLE_PANE else LayoutMode.DECK + DesktopPreferences.layoutMode = layoutMode.name + }, + ) + if (layoutMode == LayoutMode.DECK) { + Separator() + 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, + ) + val columnCount = deckState.columns.value.size + columnKeys.take(columnCount).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) }) + Item("Chess", onClick = { deckState.addColumn(DeckColumnType.Chess) }) + } + } } Menu("Help") { Item("About Amethyst", onClick = { }) @@ -227,9 +346,10 @@ fun main() = } App( - currentScreen = currentScreen, - onScreenChange = { currentScreen = it }, + layoutMode = layoutMode, + deckState = deckState, showComposeDialog = showComposeDialog, + showAddColumnDialog = showAddColumnDialog, onShowComposeDialog = { showComposeDialog = true }, onShowReplyDialog = { event -> replyToNote = event @@ -239,6 +359,8 @@ fun main() = showComposeDialog = false replyToNote = null }, + onDismissAddColumnDialog = { showAddColumnDialog = false }, + onShowAddColumnDialog = { showAddColumnDialog = true }, replyToNote = replyToNote, ) } @@ -246,12 +368,15 @@ fun main() = @Composable fun App( - currentScreen: DesktopScreen, - onScreenChange: (DesktopScreen) -> Unit, + layoutMode: LayoutMode, + 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() } @@ -301,7 +426,7 @@ fun App( is AccountState.LoggedOut -> { LoginScreen( accountManager = accountManager, - onLoginSuccess = { onScreenChange(DesktopScreen.Feed) }, + onLoginSuccess = { }, ) } @@ -315,16 +440,18 @@ fun App( } MainContent( - currentScreen = currentScreen, - onScreenChange = onScreenChange, + layoutMode = layoutMode, + deckState = deckState, relayManager = relayManager, localCache = localCache, accountManager = accountManager, account = account, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, + appScope = scope, onShowComposeDialog = onShowComposeDialog, onShowReplyDialog = onShowReplyDialog, + onShowAddColumnDialog = onShowAddColumnDialog, ) // Compose dialog @@ -336,6 +463,17 @@ fun App( replyTo = replyToNote, ) } + + // Add column dialog + if (showAddColumnDialog) { + AddColumnDialog( + onDismiss = onDismissAddColumnDialog, + onAdd = { type -> + deckState.addColumn(type) + onDismissAddColumnDialog() + }, + ) + } } } } @@ -344,16 +482,18 @@ fun App( @Composable fun MainContent( - currentScreen: DesktopScreen, - onScreenChange: (DesktopScreen) -> Unit, + layoutMode: LayoutMode, + 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() @@ -465,234 +605,51 @@ 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)) + 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), + ) + } - NavigationRailItem( - icon = { Icon(Icons.Default.Home, contentDescription = "Feed") }, - label = { Text("Feed") }, - selected = currentScreen == DesktopScreen.Feed, - onClick = { onScreenChange(DesktopScreen.Feed) }, - ) + LayoutMode.DECK -> { + DeckSidebar( + onAddColumn = onShowAddColumnDialog, + onOpenSettings = { + if (deckState.hasColumnOfType(DeckColumnType.Settings)) { + deckState.focusExistingColumn(DeckColumnType.Settings) + } else { + deckState.addColumn(DeckColumnType.Settings) + } + }, + ) - NavigationRailItem( - icon = { Icon(Icons.AutoMirrored.Filled.Article, contentDescription = "Reads") }, - label = { Text("Reads") }, - selected = currentScreen == DesktopScreen.Reads, - onClick = { onScreenChange(DesktopScreen.Reads) }, - ) + VerticalDivider() - 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.Extension, contentDescription = "Chess") }, - label = { Text("Chess") }, - selected = currentScreen == DesktopScreen.Chess, - onClick = { onScreenChange(DesktopScreen.Chess) }, - ) - - 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)) - } - - 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 -> { - DesktopMessagesScreen( - account = iAccount, - cacheProvider = localCache, - relayManager = relayManager, - localCache = localCache, - onNavigateToProfile = { pubKeyHex -> - onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) - }, - ) - } - - DesktopScreen.Notifications -> { - NotificationsScreen(relayManager, account, subscriptionsCoordinator) - } - - DesktopScreen.Chess -> { - ChessScreen( - relayManager = relayManager, - account = account, - onBack = { onScreenChange(DesktopScreen.Feed) }, - ) - } - - 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), + ) } } } 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/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index b4268710f..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 @@ -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 @@ -149,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 = {}, @@ -168,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 @@ -475,16 +478,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/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/AddColumnDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt new file mode 100644 index 000000000..f7ef606dd --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.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 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, + DeckColumnType.Chess, + ) + +@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..3deb62b25 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt @@ -0,0 +1,138 @@ +/* + * 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.gestures.detectTapGestures +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.Extension +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.input.pointer.pointerInput +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, + onDoubleClick: () -> Unit = {}, + modifier: Modifier = Modifier, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .height(40.dp) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .pointerInput(Unit) { + detectTapGestures( + onDoubleTap = { onDoubleClick() }, + ) + }.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.Chess -> Icons.Default.Extension + DeckColumnType.Settings -> Icons.Default.Settings + is DeckColumnType.Profile -> Icons.Default.Person + 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 new file mode 100644 index 000000000..ce90b7ef6 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -0,0 +1,390 @@ +/* + * 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.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 +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 +import kotlinx.coroutines.flow.asStateFlow + +class ColumnNavigationState { + private val _stack = MutableStateFlow>(emptyList()) + val stack: kotlinx.coroutines.flow.StateFlow> = _stack.asStateFlow() + + 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, + onDoubleClickHeader: () -> 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, + onDoubleClick = onDoubleClickHeader, + ) + + 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 +internal 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, + initialFeedMode = FeedMode.FOLLOWING, + 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, + relayManager = relayManager, + localCache = 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, + initialFeedMode = FeedMode.GLOBAL, + 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.Chess -> { + ChessScreen( + relayManager = relayManager, + account = account, + onBack = {}, + compactMode = true, + ) + } + + 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, + initialQuery = "#${columnType.tag}", + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + ) + } + } +} + +@Composable +internal 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 -> { + 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/DeckColumnType.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt new file mode 100644 index 000000000..2b1642244 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt @@ -0,0 +1,97 @@ +/* + * 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 Chess : 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" + Chess -> "Chess" + 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" + Chess -> "chess" + 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..1df6bf135 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt @@ -0,0 +1,144 @@ +/* + * 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.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 +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 +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 density = LocalDensity.current + + BoxWithConstraints(modifier = modifier.fillMaxSize()) { + val availableWidthDp = with(density) { constraints.maxWidth.toDp().value } + deckState.setAvailableWidth(availableWidthDp) + + // 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( + 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, + ) + } + } + } +} + +@Composable +private fun DraggableDivider(onDrag: (Float) -> Unit) { + Box( + modifier = + Modifier + .width(12.dp) + .fillMaxHeight() + .pointerHoverIcon(PointerIcon(Cursor(Cursor.E_RESIZE_CURSOR))) + .pointerInput(Unit) { + detectDragGestures { change, dragAmount -> + change.consume() + 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/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..bdfb40298 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt @@ -0,0 +1,295 @@ +/* + * 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.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( + 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 + } + + fun addColumn( + type: DeckColumnType, + afterIndex: Int? = null, + ) { + val col = DeckColumn(type = type) + _columns.update { current -> + if (afterIndex != null && afterIndex < current.size) { + current.toMutableList().apply { add(afterIndex + 1, col) } + } else { + current + col + } + } + // Auto-fit all columns to available width when known + val width = lastKnownWidth + if (width > 0f) { + fitColumnsToWidth(width) + } else { + 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) { + _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)) + } + } + } + result + } + _focusedColumnIndex.update { idx -> + idx.coerceAtMost(_columns.value.size - 1) + } + scheduleSave() + } + + fun moveColumn( + fromIndex: Int, + toIndex: Int, + ) { + _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.update { current -> + current.map { + if (it.id == id) it.copy(width = width.coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)) else it + } + } + scheduleSave() + } + + fun expandColumn( + id: String, + availableWidth: Float, + ) { + _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) + } else { + it.copy(width = MIN_COLUMN_WIDTH) + } + } + } + scheduleSave() + } + + fun resizePair( + leftId: String, + rightId: String, + delta: Float, + availableWidth: Float, + ) { + _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) + } + } + cols.map { + when (it.id) { + leftId -> it.copy(width = newLeft) + rightId -> it.copy(width = newRight) + else -> it + } + } + } + scheduleSave() + } + + fun fitColumnsToWidth(availableWidth: Float) { + _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) { + if (index in _columns.value.indices) { + _focusedColumnIndex.value = index + } + } + + private fun scheduleSave() { + saveJob?.cancel() + saveJob = + saveScope.launch { + delay(SAVE_DEBOUNCE_MS) + save() + } + } + + 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 (e: Exception) { + println("DeckState: failed to save columns: ${e.message}") + } + } + + 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 (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 = 12f + private const val SAVE_DEBOUNCE_MS = 500L + + val DEFAULT_COLUMNS = + listOf( + 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() + + 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 + "chess" -> DeckColumnType.Chess + "settings" -> DeckColumnType.Settings + "profile" -> param?.let { DeckColumnType.Profile(it) } + "thread" -> param?.let { DeckColumnType.Thread(it) } + "hashtag" -> param?.let { DeckColumnType.Hashtag(it) } + else -> null + } + } + } +} 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..dd8276699 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt @@ -0,0 +1,233 @@ +/* + * 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.Extension +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.Chess, Icons.Default.Extension, "Chess"), + 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, + ) + } +}