feat(desktop): add multi-column deck layout replacing NavigationRail

Replace the 80dp NavigationRail + single content area with a TweetDeck-style
multi-column deck. Default layout: Home Feed + Notifications + Messages.

New deck system:
- DeckColumnType sealed class with 12 column types
- DeckState with StateFlow-based column management
- Per-column navigation stack for in-column drill-down
- Draggable dividers for column resize (300-800dp)
- Column config persisted via Jackson JSON in DesktopPreferences
- 48dp thin sidebar with add-column and settings buttons
- AddColumnDialog for picking column types

Keyboard shortcuts:
- Cmd+T: Add column
- Cmd+W: Close focused column
- Cmd+1-9: Focus column by index
- Cmd+Shift+Left/Right: Move column left/right

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-02-23 11:53:31 +02:00
parent 35a15f0462
commit e7239980c6
9 changed files with 1279 additions and 254 deletions
@@ -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)
}
}
@@ -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<com.vitorpamplona.quartz.nip01Core.core.Event?>(null) }
var currentScreen by remember { mutableStateOf<DesktopScreen>(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
@@ -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")
}
},
)
}
@@ -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
}
@@ -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<List<DesktopScreen>>(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 -> {}
}
}
@@ -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,
)
@@ -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,
)
}
}
@@ -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,
)
}
}
}
@@ -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<List<DeckColumn>> = _columns.asStateFlow()
private val _focusedColumnIndex = MutableStateFlow(0)
val focusedColumnIndex: StateFlow<Int> = _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<Map<String, Any?>> = 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<String, Any?>): 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
}
}
}
}