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 + } + } + } +}