Merge pull request #1760 from nrobi144/feat/desktop-deck-layout

feat(desktop): multi-column deck layout with single-pane toggle
This commit is contained in:
Vitor Pamplona
2026-03-04 08:26:32 -05:00
committed by GitHub
14 changed files with 2003 additions and 444 deletions
@@ -35,6 +35,8 @@ object DesktopPreferences {
private const val KEY_FEED_MODE = "feed_mode" private const val KEY_FEED_MODE = "feed_mode"
private const val KEY_LAST_SCREEN = "last_screen" private const val KEY_LAST_SCREEN = "last_screen"
private const val KEY_DECK_COLUMNS = "deck_columns"
private const val KEY_LAYOUT_MODE = "layout_mode"
var feedMode: FeedMode var feedMode: FeedMode
get() { get() {
@@ -54,4 +56,16 @@ object DesktopPreferences {
set(value) { set(value) {
prefs.put(KEY_LAST_SCREEN, value) prefs.put(KEY_LAST_SCREEN, value)
} }
var deckColumns: String
get() = prefs.get(KEY_DECK_COLUMNS, "")
set(value) {
prefs.put(KEY_DECK_COLUMNS, value)
}
var layoutMode: String
get() = prefs.get(KEY_LAYOUT_MODE, "SINGLE_PANE")
set(value) {
prefs.put(KEY_LAYOUT_MODE, value)
}
} }
@@ -25,32 +25,20 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Article
import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.Extension
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
@@ -82,23 +70,20 @@ import androidx.compose.ui.window.rememberWindowState
import com.vitorpamplona.amethyst.desktop.account.AccountManager import com.vitorpamplona.amethyst.desktop.account.AccountManager
import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.chess.ChessScreen
import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator 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.ComposeNoteDialog
import com.vitorpamplona.amethyst.desktop.ui.FeedScreen
import com.vitorpamplona.amethyst.desktop.ui.LoginScreen 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.ZapFeedback
import com.vitorpamplona.amethyst.desktop.ui.chats.DesktopMessagesScreen
import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker
import com.vitorpamplona.amethyst.desktop.ui.deck.AddColumnDialog
import com.vitorpamplona.amethyst.desktop.ui.deck.DeckColumnType
import com.vitorpamplona.amethyst.desktop.ui.deck.DeckLayout
import com.vitorpamplona.amethyst.desktop.ui.deck.DeckSidebar
import com.vitorpamplona.amethyst.desktop.ui.deck.DeckState
import com.vitorpamplona.amethyst.desktop.ui.deck.SinglePaneLayout
import com.vitorpamplona.amethyst.desktop.ui.profile.ProfileInfoCard import com.vitorpamplona.amethyst.desktop.ui.profile.ProfileInfoCard
import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
@@ -110,8 +95,13 @@ import kotlinx.coroutines.launch
private val isMacOS = System.getProperty("os.name").lowercase().contains("mac") private val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
enum class LayoutMode {
SINGLE_PANE,
DECK,
}
/** /**
* Desktop navigation state - extends AppScreen with dynamic destinations. * Desktop navigation state — used for in-column navigation (drill-down).
*/ */
sealed class DesktopScreen { sealed class DesktopScreen {
object Feed : DesktopScreen() object Feed : DesktopScreen()
@@ -151,7 +141,18 @@ fun main() =
) )
var showComposeDialog by remember { mutableStateOf(false) } var showComposeDialog by remember { mutableStateOf(false) }
var replyToNote by remember { mutableStateOf<com.vitorpamplona.quartz.nip01Core.core.Event?>(null) } var replyToNote by remember { mutableStateOf<com.vitorpamplona.quartz.nip01Core.core.Event?>(null) }
var currentScreen by remember { mutableStateOf<DesktopScreen>(DesktopScreen.Feed) } val deckScope = rememberCoroutineScope()
val deckState = remember { DeckState(deckScope).also { it.load() } }
var showAddColumnDialog by remember { mutableStateOf(false) }
var layoutMode by remember {
mutableStateOf(
try {
LayoutMode.valueOf(DesktopPreferences.layoutMode)
} catch (e: Exception) {
LayoutMode.SINGLE_PANE
},
)
}
Window( Window(
onCloseRequest = ::exitApplication, onCloseRequest = ::exitApplication,
@@ -179,7 +180,13 @@ fun main() =
} else { } else {
KeyShortcut(Key.Comma, ctrl = true) KeyShortcut(Key.Comma, ctrl = true)
}, },
onClick = { currentScreen = DesktopScreen.Settings }, onClick = {
if (deckState.hasColumnOfType(DeckColumnType.Settings)) {
deckState.focusExistingColumn(DeckColumnType.Settings)
} else {
deckState.addColumn(DeckColumnType.Settings)
}
},
) )
Separator() Separator()
Item( Item(
@@ -216,9 +223,121 @@ fun main() =
) )
} }
Menu("View") { Menu("View") {
Item("Feed", onClick = { }) Item(
Item("Messages", onClick = { }) if (layoutMode == LayoutMode.DECK) "\u2713 Deck Layout" else "Deck Layout",
Item("Notifications", onClick = { }) shortcut =
if (isMacOS) {
KeyShortcut(Key.D, meta = true, shift = true)
} else {
KeyShortcut(Key.D, ctrl = true, shift = true)
},
onClick = {
layoutMode =
if (layoutMode == LayoutMode.DECK) LayoutMode.SINGLE_PANE else LayoutMode.DECK
DesktopPreferences.layoutMode = layoutMode.name
},
)
if (layoutMode == LayoutMode.DECK) {
Separator()
Item(
"Add Column",
shortcut =
if (isMacOS) {
KeyShortcut(Key.T, meta = true)
} else {
KeyShortcut(Key.T, ctrl = true)
},
onClick = { showAddColumnDialog = true },
)
Item(
"Close Column",
shortcut =
if (isMacOS) {
KeyShortcut(Key.W, meta = true)
} else {
KeyShortcut(Key.W, ctrl = true)
},
onClick = {
val cols = deckState.columns.value
val idx = deckState.focusedColumnIndex.value
if (cols.size > 1 && idx in cols.indices) {
deckState.removeColumn(cols[idx].id)
}
},
)
Item(
"Move Column Left",
shortcut =
if (isMacOS) {
KeyShortcut(Key.DirectionLeft, meta = true, shift = true)
} else {
KeyShortcut(Key.DirectionLeft, ctrl = true, shift = true)
},
onClick = {
val idx = deckState.focusedColumnIndex.value
if (idx > 0) {
deckState.moveColumn(idx, idx - 1)
deckState.focusColumn(idx - 1)
}
},
)
Item(
"Move Column Right",
shortcut =
if (isMacOS) {
KeyShortcut(Key.DirectionRight, meta = true, shift = true)
} else {
KeyShortcut(Key.DirectionRight, ctrl = true, shift = true)
},
onClick = {
val idx = deckState.focusedColumnIndex.value
val size = deckState.columns.value.size
if (idx < size - 1) {
deckState.moveColumn(idx, idx + 1)
deckState.focusColumn(idx + 1)
}
},
)
Separator()
// Focus column by index (Cmd/Ctrl+1..9)
val columnKeys =
listOf(
Key.One,
Key.Two,
Key.Three,
Key.Four,
Key.Five,
Key.Six,
Key.Seven,
Key.Eight,
Key.Nine,
)
val columnCount = deckState.columns.value.size
columnKeys.take(columnCount).forEachIndexed { i, key ->
Item(
"Column ${i + 1}",
shortcut =
if (isMacOS) {
KeyShortcut(key, meta = true)
} else {
KeyShortcut(key, ctrl = true)
},
onClick = { deckState.focusColumn(i) },
)
}
Separator()
Menu("Add Column...") {
Item("Home Feed", onClick = { deckState.addColumn(DeckColumnType.HomeFeed) })
Item("Notifications", onClick = { deckState.addColumn(DeckColumnType.Notifications) })
Item("Messages", onClick = { deckState.addColumn(DeckColumnType.Messages) })
Item("Search", onClick = { deckState.addColumn(DeckColumnType.Search) })
Item("Reads", onClick = { deckState.addColumn(DeckColumnType.Reads) })
Item("Bookmarks", onClick = { deckState.addColumn(DeckColumnType.Bookmarks) })
Item("Global Feed", onClick = { deckState.addColumn(DeckColumnType.GlobalFeed) })
Item("Profile", onClick = { deckState.addColumn(DeckColumnType.MyProfile) })
Item("Chess", onClick = { deckState.addColumn(DeckColumnType.Chess) })
}
}
} }
Menu("Help") { Menu("Help") {
Item("About Amethyst", onClick = { }) Item("About Amethyst", onClick = { })
@@ -227,9 +346,10 @@ fun main() =
} }
App( App(
currentScreen = currentScreen, layoutMode = layoutMode,
onScreenChange = { currentScreen = it }, deckState = deckState,
showComposeDialog = showComposeDialog, showComposeDialog = showComposeDialog,
showAddColumnDialog = showAddColumnDialog,
onShowComposeDialog = { showComposeDialog = true }, onShowComposeDialog = { showComposeDialog = true },
onShowReplyDialog = { event -> onShowReplyDialog = { event ->
replyToNote = event replyToNote = event
@@ -239,6 +359,8 @@ fun main() =
showComposeDialog = false showComposeDialog = false
replyToNote = null replyToNote = null
}, },
onDismissAddColumnDialog = { showAddColumnDialog = false },
onShowAddColumnDialog = { showAddColumnDialog = true },
replyToNote = replyToNote, replyToNote = replyToNote,
) )
} }
@@ -246,12 +368,15 @@ fun main() =
@Composable @Composable
fun App( fun App(
currentScreen: DesktopScreen, layoutMode: LayoutMode,
onScreenChange: (DesktopScreen) -> Unit, deckState: DeckState,
showComposeDialog: Boolean, showComposeDialog: Boolean,
showAddColumnDialog: Boolean,
onShowComposeDialog: () -> Unit, onShowComposeDialog: () -> Unit,
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
onDismissComposeDialog: () -> Unit, onDismissComposeDialog: () -> Unit,
onDismissAddColumnDialog: () -> Unit,
onShowAddColumnDialog: () -> Unit,
replyToNote: com.vitorpamplona.quartz.nip01Core.core.Event?, replyToNote: com.vitorpamplona.quartz.nip01Core.core.Event?,
) { ) {
val relayManager = remember { DesktopRelayConnectionManager() } val relayManager = remember { DesktopRelayConnectionManager() }
@@ -301,7 +426,7 @@ fun App(
is AccountState.LoggedOut -> { is AccountState.LoggedOut -> {
LoginScreen( LoginScreen(
accountManager = accountManager, accountManager = accountManager,
onLoginSuccess = { onScreenChange(DesktopScreen.Feed) }, onLoginSuccess = { },
) )
} }
@@ -315,16 +440,18 @@ fun App(
} }
MainContent( MainContent(
currentScreen = currentScreen, layoutMode = layoutMode,
onScreenChange = onScreenChange, deckState = deckState,
relayManager = relayManager, relayManager = relayManager,
localCache = localCache, localCache = localCache,
accountManager = accountManager, accountManager = accountManager,
account = account, account = account,
nwcConnection = nwcConnection, nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator, subscriptionsCoordinator = subscriptionsCoordinator,
appScope = scope,
onShowComposeDialog = onShowComposeDialog, onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog, onShowReplyDialog = onShowReplyDialog,
onShowAddColumnDialog = onShowAddColumnDialog,
) )
// Compose dialog // Compose dialog
@@ -336,6 +463,17 @@ fun App(
replyTo = replyToNote, replyTo = replyToNote,
) )
} }
// Add column dialog
if (showAddColumnDialog) {
AddColumnDialog(
onDismiss = onDismissAddColumnDialog,
onAdd = { type ->
deckState.addColumn(type)
onDismissAddColumnDialog()
},
)
}
} }
} }
} }
@@ -344,16 +482,18 @@ fun App(
@Composable @Composable
fun MainContent( fun MainContent(
currentScreen: DesktopScreen, layoutMode: LayoutMode,
onScreenChange: (DesktopScreen) -> Unit, deckState: DeckState,
relayManager: DesktopRelayConnectionManager, relayManager: DesktopRelayConnectionManager,
localCache: DesktopLocalCache, localCache: DesktopLocalCache,
accountManager: AccountManager, accountManager: AccountManager,
account: AccountState.LoggedIn, account: AccountState.LoggedIn,
nwcConnection: Nip47WalletConnect.Nip47URINorm?, nwcConnection: Nip47WalletConnect.Nip47URINorm?,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
appScope: CoroutineScope,
onShowComposeDialog: () -> Unit, onShowComposeDialog: () -> Unit,
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
onShowAddColumnDialog: () -> Unit,
) { ) {
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -465,234 +605,51 @@ fun MainContent(
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
Row(Modifier.fillMaxSize()) { Row(Modifier.fillMaxSize()) {
// Sidebar Navigation when (layoutMode) {
NavigationRail( LayoutMode.SINGLE_PANE -> {
modifier = Modifier.width(80.dp).fillMaxHeight(), SinglePaneLayout(
containerColor = MaterialTheme.colorScheme.surfaceVariant, relayManager = relayManager,
) { localCache = localCache,
Spacer(Modifier.height(16.dp)) accountManager = accountManager,
account = account,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
appScope = appScope,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
onZapFeedback = onZapFeedback,
modifier = Modifier.weight(1f),
)
}
NavigationRailItem( LayoutMode.DECK -> {
icon = { Icon(Icons.Default.Home, contentDescription = "Feed") }, DeckSidebar(
label = { Text("Feed") }, onAddColumn = onShowAddColumnDialog,
selected = currentScreen == DesktopScreen.Feed, onOpenSettings = {
onClick = { onScreenChange(DesktopScreen.Feed) }, if (deckState.hasColumnOfType(DeckColumnType.Settings)) {
) deckState.focusExistingColumn(DeckColumnType.Settings)
} else {
deckState.addColumn(DeckColumnType.Settings)
}
},
)
NavigationRailItem( VerticalDivider()
icon = { Icon(Icons.AutoMirrored.Filled.Article, contentDescription = "Reads") },
label = { Text("Reads") },
selected = currentScreen == DesktopScreen.Reads,
onClick = { onScreenChange(DesktopScreen.Reads) },
)
NavigationRailItem( DeckLayout(
icon = { Icon(Icons.Default.Search, contentDescription = "Search") }, deckState = deckState,
label = { Text("Search") }, relayManager = relayManager,
selected = currentScreen == DesktopScreen.Search, localCache = localCache,
onClick = { onScreenChange(DesktopScreen.Search) }, accountManager = accountManager,
) account = account,
nwcConnection = nwcConnection,
NavigationRailItem( subscriptionsCoordinator = subscriptionsCoordinator,
icon = { Icon(com.vitorpamplona.amethyst.commons.icons.Bookmark, contentDescription = "Bookmarks") }, appScope = appScope,
label = { Text("Bookmarks") }, onShowComposeDialog = onShowComposeDialog,
selected = currentScreen == DesktopScreen.Bookmarks, onShowReplyDialog = onShowReplyDialog,
onClick = { onScreenChange(DesktopScreen.Bookmarks) }, onZapFeedback = onZapFeedback,
) modifier = Modifier.weight(1f),
)
NavigationRailItem(
icon = { Icon(Icons.Default.Email, contentDescription = "Messages") },
label = { Text("DMs") },
selected = currentScreen == DesktopScreen.Messages,
onClick = { onScreenChange(DesktopScreen.Messages) },
)
NavigationRailItem(
icon = { Icon(Icons.Default.Notifications, contentDescription = "Notifications") },
label = { Text("Alerts") },
selected = currentScreen == DesktopScreen.Notifications,
onClick = { onScreenChange(DesktopScreen.Notifications) },
)
NavigationRailItem(
icon = { Icon(Icons.Default.Extension, contentDescription = "Chess") },
label = { Text("Chess") },
selected = currentScreen == DesktopScreen.Chess,
onClick = { onScreenChange(DesktopScreen.Chess) },
)
NavigationRailItem(
icon = { Icon(Icons.Default.Person, contentDescription = "Profile") },
label = { Text("Profile") },
selected = currentScreen == DesktopScreen.MyProfile || currentScreen is DesktopScreen.UserProfile,
onClick = { onScreenChange(DesktopScreen.MyProfile) },
)
Spacer(Modifier.weight(1f))
HorizontalDivider(Modifier.padding(horizontal = 16.dp))
NavigationRailItem(
icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") },
label = { Text("Settings") },
selected = currentScreen == DesktopScreen.Settings,
onClick = { onScreenChange(DesktopScreen.Settings) },
)
Spacer(Modifier.height(16.dp))
}
VerticalDivider()
// Main Content
Box(
modifier = Modifier.weight(1f).fillMaxHeight().padding(24.dp),
) {
when (currentScreen) {
DesktopScreen.Feed -> {
FeedScreen(
relayManager = relayManager,
localCache = localCache,
account = account,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
onCompose = onShowComposeDialog,
onNavigateToProfile = { pubKeyHex ->
onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
},
onNavigateToThread = { noteId ->
onScreenChange(DesktopScreen.Thread(noteId))
},
onZapFeedback = onZapFeedback,
)
}
DesktopScreen.Reads -> {
ReadsScreen(
relayManager = relayManager,
localCache = localCache,
account = account,
onNavigateToProfile = { pubKeyHex ->
onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
},
onNavigateToArticle = { noteId ->
onScreenChange(DesktopScreen.Thread(noteId))
},
)
}
DesktopScreen.Search -> {
SearchScreen(
localCache = localCache,
relayManager = relayManager,
subscriptionsCoordinator = subscriptionsCoordinator,
onNavigateToProfile = { pubKeyHex ->
onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
},
onNavigateToThread = { noteId ->
onScreenChange(DesktopScreen.Thread(noteId))
},
)
}
DesktopScreen.Bookmarks -> {
BookmarksScreen(
relayManager = relayManager,
localCache = localCache,
account = account,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
onNavigateToProfile = { pubKeyHex ->
onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
},
onNavigateToThread = { noteId ->
onScreenChange(DesktopScreen.Thread(noteId))
},
onZapFeedback = onZapFeedback,
)
}
DesktopScreen.Messages -> {
DesktopMessagesScreen(
account = iAccount,
cacheProvider = localCache,
relayManager = relayManager,
localCache = localCache,
onNavigateToProfile = { pubKeyHex ->
onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
},
)
}
DesktopScreen.Notifications -> {
NotificationsScreen(relayManager, account, subscriptionsCoordinator)
}
DesktopScreen.Chess -> {
ChessScreen(
relayManager = relayManager,
account = account,
onBack = { onScreenChange(DesktopScreen.Feed) },
)
}
DesktopScreen.MyProfile -> {
UserProfileScreen(
pubKeyHex = account.pubKeyHex,
relayManager = relayManager,
localCache = localCache,
account = account,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
onBack = { onScreenChange(DesktopScreen.Feed) },
onCompose = onShowComposeDialog,
onNavigateToProfile = { pubKeyHex ->
onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
},
onZapFeedback = onZapFeedback,
)
}
is DesktopScreen.UserProfile -> {
UserProfileScreen(
pubKeyHex = currentScreen.pubKeyHex,
relayManager = relayManager,
localCache = localCache,
account = account,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
onBack = { onScreenChange(DesktopScreen.Feed) },
onCompose = onShowComposeDialog,
onNavigateToProfile = { pubKeyHex ->
onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
},
onZapFeedback = onZapFeedback,
)
}
is DesktopScreen.Thread -> {
ThreadScreen(
noteId = currentScreen.noteId,
relayManager = relayManager,
localCache = localCache,
account = account,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
onBack = { onScreenChange(DesktopScreen.Feed) },
onNavigateToProfile = { pubKeyHex ->
onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
},
onNavigateToThread = { noteId ->
onScreenChange(DesktopScreen.Thread(noteId))
},
onZapFeedback = onZapFeedback,
onReply = onShowReplyDialog,
)
}
DesktopScreen.Settings -> {
RelaySettingsScreen(relayManager, account, accountManager)
}
} }
} }
} }
@@ -39,6 +39,8 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
@@ -90,6 +92,7 @@ fun ChessScreen(
relayManager: DesktopRelayConnectionManager, relayManager: DesktopRelayConnectionManager,
account: AccountState.LoggedIn, account: AccountState.LoggedIn,
onBack: () -> Unit = {}, onBack: () -> Unit = {},
compactMode: Boolean = false,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val viewModel = val viewModel =
@@ -274,6 +277,7 @@ fun ChessScreen(
}, },
onResign = { viewModel.resign(gameState.startEventId) }, onResign = { viewModel.resign(gameState.startEventId) },
isSpectatorOverride = isSpectating, isSpectatorOverride = isSpectating,
compactMode = compactMode,
) )
} }
} else { } else {
@@ -593,6 +597,7 @@ private fun DesktopChessGameLayout(
onMoveMade: (from: String, to: String, san: String) -> Unit, onMoveMade: (from: String, to: String, san: String) -> Unit,
onResign: () -> Unit, onResign: () -> Unit,
isSpectatorOverride: Boolean? = null, isSpectatorOverride: Boolean? = null,
compactMode: Boolean = false,
) { ) {
// Collect state flows to trigger recomposition on changes // Collect state flows to trigger recomposition on changes
val currentPosition by gameState.currentPosition.collectAsState() val currentPosition by gameState.currentPosition.collectAsState()
@@ -604,12 +609,13 @@ private fun DesktopChessGameLayout(
val opponentPubkey = gameState.opponentPubkey val opponentPubkey = gameState.opponentPubkey
val isSpectator = isSpectatorOverride ?: gameState.isSpectator val isSpectator = isSpectatorOverride ?: gameState.isSpectator
var showInfoPanel by remember { mutableStateOf(!compactMode) }
BoxWithConstraints( BoxWithConstraints(
modifier = Modifier.fillMaxSize().padding(16.dp), modifier = Modifier.fillMaxSize().padding(16.dp),
) { ) {
// Calculate board size based on available space // Calculate board size based on available space
// Leave room for the info panel (300dp + 24dp spacing) val infoPanelWidth = if (showInfoPanel) 300.dp + 24.dp else 0.dp
val infoPanelWidth = 300.dp + 24.dp
val availableWidth = maxWidth - infoPanelWidth val availableWidth = maxWidth - infoPanelWidth
val availableHeight = maxHeight val availableHeight = maxHeight
// Board should fit within available space, maintaining square aspect ratio // Board should fit within available space, maintaining square aspect ratio
@@ -617,11 +623,11 @@ private fun DesktopChessGameLayout(
Row( Row(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.spacedBy(24.dp), horizontalArrangement = Arrangement.spacedBy(if (showInfoPanel) 24.dp else 0.dp),
) { ) {
// Left side: Chess board // Left side: Chess board + toggle
Box( Box(
modifier = Modifier.fillMaxHeight(), modifier = Modifier.weight(1f).fillMaxHeight(),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
InteractiveChessBoard( InteractiveChessBoard(
@@ -633,188 +639,206 @@ private fun DesktopChessGameLayout(
positionVersion = moveHistory.size, positionVersion = moveHistory.size,
onMoveMade = onMoveMade, onMoveMade = onMoveMade,
) )
// Toggle button anchored to top-end of board area
IconButton(
onClick = { showInfoPanel = !showInfoPanel },
modifier = Modifier.align(Alignment.TopEnd),
) {
Icon(
if (showInfoPanel) {
Icons.AutoMirrored.Filled.KeyboardArrowRight
} else {
Icons.AutoMirrored.Filled.KeyboardArrowLeft
},
contentDescription = if (showInfoPanel) "Hide info panel" else "Show info panel",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} }
// Right side: Game info, moves, controls // Right side: Game info, moves, controls (collapsible)
Column( if (showInfoPanel) {
modifier = Column(
Modifier modifier =
.width(300.dp) Modifier
.fillMaxHeight() .width(300.dp)
.verticalScroll(rememberScrollState()), .fillMaxHeight()
verticalArrangement = Arrangement.spacedBy(16.dp), .verticalScroll(rememberScrollState()),
) { verticalArrangement = Arrangement.spacedBy(16.dp),
// Extract human-readable game name
val gameName =
remember(startEventId) {
ChessGameNameGenerator.extractDisplayName(startEventId)
}
// Game info card
Card(
modifier = Modifier.fillMaxWidth(),
) { ) {
Column( // Extract human-readable game name
modifier = Modifier.padding(16.dp), val gameName =
verticalArrangement = Arrangement.spacedBy(8.dp), remember(startEventId) {
) { ChessGameNameGenerator.extractDisplayName(startEventId)
// Show readable game name if available
if (gameName != null) {
Text(
gameName,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
} else {
Text(
"Game Info",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
} }
Row( // Game info card
verticalAlignment = Alignment.CenterVertically, Card(
horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
UserAvatar( // Show readable game name if available
userHex = opponentPubkey, if (gameName != null) {
pictureUrl = opponentPicture, Text(
size = 48.dp, gameName,
) style = MaterialTheme.typography.titleLarge,
Column { fontWeight = FontWeight.Bold,
if (isSpectator) { color = MaterialTheme.colorScheme.primary,
Text( )
"Spectating", } else {
style = MaterialTheme.typography.bodyLarge, Text(
fontWeight = FontWeight.Bold, "Game Info",
color = MaterialTheme.colorScheme.tertiary, style = MaterialTheme.typography.titleMedium,
) fontWeight = FontWeight.Bold,
} else { )
Text( }
"vs $opponentName",
style = MaterialTheme.typography.bodyLarge, Row(
) verticalAlignment = Alignment.CenterVertically,
Text( horizontalArrangement = Arrangement.spacedBy(12.dp),
"You play ${if (playerColor == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}", ) {
style = MaterialTheme.typography.bodyMedium, UserAvatar(
color = MaterialTheme.colorScheme.onSurfaceVariant, userHex = opponentPubkey,
) pictureUrl = opponentPicture,
size = 48.dp,
)
Column {
if (isSpectator) {
Text(
"Spectating",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.tertiary,
)
} else {
Text(
"vs $opponentName",
style = MaterialTheme.typography.bodyLarge,
)
Text(
"You play ${if (playerColor == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
// Use currentPosition to derive turn (triggers recomposition on move)
val currentTurn = currentPosition.activeColor
if (isSpectator) {
Text(
"${if (currentTurn == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}'s turn",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
val isYourTurn = currentTurn == playerColor
Text(
if (isYourTurn) "Your turn" else "Opponent's turn",
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (isYourTurn) FontWeight.Bold else FontWeight.Normal,
color =
if (isYourTurn) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
}
// Move history card
if (moveHistory.isNotEmpty()) {
Card(
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
"Move History",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
// Format moves as numbered pairs
val moveText =
moveHistory
.chunked(2)
.mapIndexed { index, pair ->
"${index + 1}. ${pair.joinToString(" ")}"
}.joinToString("\n")
Text(
moveText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
// Game controls card (only for participants, not spectators)
if (!isSpectator) {
Card(
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
"Actions",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
OutlinedButton(
onClick = onResign,
modifier = Modifier.fillMaxWidth(),
) {
Text("Resign")
} }
} }
} }
} else {
// Use currentPosition to derive turn (triggers recomposition on move) // Spectator info card
val currentTurn = currentPosition.activeColor Card(
modifier = Modifier.fillMaxWidth(),
if (isSpectator) { colors =
Text( CardDefaults.cardColors(
"${if (currentTurn == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}'s turn", containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f),
style = MaterialTheme.typography.bodyMedium, ),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
val isYourTurn = currentTurn == playerColor
Text(
if (isYourTurn) "Your turn" else "Opponent's turn",
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (isYourTurn) FontWeight.Bold else FontWeight.Normal,
color =
if (isYourTurn) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
}
// Move history card
if (moveHistory.isNotEmpty()) {
Card(
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Text( Row(
"Move History", modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.titleMedium, verticalAlignment = Alignment.CenterVertically,
fontWeight = FontWeight.Bold, horizontalArrangement = Arrangement.spacedBy(8.dp),
)
// Format moves as numbered pairs
val moveText =
moveHistory
.chunked(2)
.mapIndexed { index, pair ->
"${index + 1}. ${pair.joinToString(" ")}"
}.joinToString("\n")
Text(
moveText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
// Game controls card (only for participants, not spectators)
if (!isSpectator) {
Card(
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
"Actions",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
OutlinedButton(
onClick = onResign,
modifier = Modifier.fillMaxWidth(),
) { ) {
Text("Resign") Icon(Icons.Default.Visibility, contentDescription = null)
Text(
"Watching game - spectator mode",
style = MaterialTheme.typography.bodyMedium,
)
} }
} }
} }
} else {
// Spectator info card
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f),
),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(Icons.Default.Visibility, contentDescription = null)
Text(
"Watching game - spectator mode",
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
// Game ID (small footer) // Game ID (small footer)
Text( Text(
"Game: ${startEventId.take(16)}...", "Game: ${startEventId.take(16)}...",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
}
} }
} }
} }
@@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.desktop.ui
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -149,6 +151,7 @@ fun FeedScreen(
account: AccountState.LoggedIn? = null, account: AccountState.LoggedIn? = null,
nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null, nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
initialFeedMode: FeedMode? = null,
onCompose: () -> Unit = {}, onCompose: () -> Unit = {},
onNavigateToProfile: (String) -> Unit = {}, onNavigateToProfile: (String) -> Unit = {},
onNavigateToThread: (String) -> Unit = {}, onNavigateToThread: (String) -> Unit = {},
@@ -168,7 +171,7 @@ fun FeedScreen(
} }
val events by eventState.items.collectAsState() val events by eventState.items.collectAsState()
var replyToEvent by remember { mutableStateOf<Event?>(null) } var replyToEvent by remember { mutableStateOf<Event?>(null) }
var feedMode by remember { mutableStateOf(DesktopPreferences.feedMode) } var feedMode by remember { mutableStateOf(initialFeedMode ?: DesktopPreferences.feedMode) }
var followedUsers by remember { mutableStateOf<Set<String>>(emptySet()) } var followedUsers by remember { mutableStateOf<Set<String>>(emptySet()) }
var zapsByEvent by remember { mutableStateOf<Map<String, List<ZapReceipt>>>(emptyMap()) } var zapsByEvent by remember { mutableStateOf<Map<String, List<ZapReceipt>>>(emptyMap()) }
// Track reaction event IDs per target event to deduplicate // Track reaction event IDs per target event to deduplicate
@@ -475,16 +478,17 @@ fun FeedScreen(
) )
} }
@OptIn(ExperimentalLayoutApi::class)
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
// Header with compose button // Header with compose button — wraps on narrow columns
Row( FlowRow(
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Column { Column {
Row( FlowRow(
verticalAlignment = Alignment.CenterVertically, verticalArrangement = Arrangement.Center,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Text( Text(
@@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.desktop.ui
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -259,16 +261,17 @@ fun ReadsScreen(
} }
} }
@OptIn(ExperimentalLayoutApi::class)
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
// Header // Header — wraps on narrow columns
Row( FlowRow(
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Column { Column {
Row( FlowRow(
verticalAlignment = Alignment.CenterVertically, verticalArrangement = Arrangement.Center,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Text( Text(
@@ -78,6 +78,7 @@ fun SearchScreen(
localCache: DesktopLocalCache, localCache: DesktopLocalCache,
relayManager: DesktopRelayConnectionManager, relayManager: DesktopRelayConnectionManager,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
initialQuery: String = "",
onNavigateToProfile: (String) -> Unit, onNavigateToProfile: (String) -> Unit,
onNavigateToThread: (String) -> Unit, onNavigateToThread: (String) -> Unit,
onNavigateToHashtag: (String) -> Unit = {}, onNavigateToHashtag: (String) -> Unit = {},
@@ -86,6 +87,13 @@ fun SearchScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val searchState = remember { SearchBarState(localCache, scope) } val searchState = remember { SearchBarState(localCache, scope) }
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
// Pre-fill initial query (e.g., hashtag column)
LaunchedEffect(initialQuery) {
if (initialQuery.isNotBlank()) {
searchState.updateSearchText(initialQuery)
}
}
val relayStatuses by relayManager.relayStatuses.collectAsState() val relayStatuses by relayManager.relayStatuses.collectAsState()
// Collect state from SearchBarState // Collect state from SearchBarState
@@ -0,0 +1,163 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui.deck
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
private val COLUMN_OPTIONS =
listOf(
DeckColumnType.HomeFeed,
DeckColumnType.Notifications,
DeckColumnType.Messages,
DeckColumnType.Search,
DeckColumnType.Reads,
DeckColumnType.Bookmarks,
DeckColumnType.GlobalFeed,
DeckColumnType.MyProfile,
DeckColumnType.Chess,
)
@Composable
fun AddColumnDialog(
onDismiss: () -> Unit,
onAdd: (DeckColumnType) -> Unit,
) {
var hashtagInput by remember { mutableStateOf("") }
var showHashtagInput by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Add Column") },
text = {
Column {
if (showHashtagInput) {
Text(
"Enter hashtag:",
style = MaterialTheme.typography.bodyMedium,
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = hashtagInput,
onValueChange = { hashtagInput = it.removePrefix("#") },
label = { Text("Hashtag") },
placeholder = { Text("bitcoin") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
} else {
COLUMN_OPTIONS.forEach { type ->
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable { onAdd(type) }
.padding(vertical = 10.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start,
) {
Icon(
imageVector = type.icon(),
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(12.dp))
Text(
type.title(),
style = MaterialTheme.typography.bodyLarge,
)
}
}
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable { showHashtagInput = true }
.padding(vertical = 10.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = DeckColumnType.Hashtag("").icon(),
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(12.dp))
Text(
"Hashtag...",
style = MaterialTheme.typography.bodyLarge,
)
}
}
}
},
confirmButton = {
if (showHashtagInput) {
Button(
onClick = {
if (hashtagInput.isNotBlank()) {
onAdd(DeckColumnType.Hashtag(hashtagInput.trim()))
}
},
enabled = hashtagInput.isNotBlank(),
) {
Text("Add")
}
}
},
dismissButton = {
TextButton(onClick = {
if (showHashtagInput) {
showHashtagInput = false
} else {
onDismiss()
}
}) {
Text(if (showHashtagInput) "Back" else "Cancel")
}
},
)
}
@@ -0,0 +1,138 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui.deck
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Article
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.Extension
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Tag
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@Composable
fun ColumnHeader(
column: DeckColumn,
canClose: Boolean,
hasBackStack: Boolean,
onBack: () -> Unit,
onClose: () -> Unit,
onDoubleClick: () -> Unit = {},
modifier: Modifier = Modifier,
) {
Row(
modifier =
modifier
.fillMaxWidth()
.height(40.dp)
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
.pointerInput(Unit) {
detectTapGestures(
onDoubleTap = { onDoubleClick() },
)
}.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (hasBackStack) {
IconButton(onClick = onBack, modifier = Modifier.size(28.dp)) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.width(4.dp))
}
Icon(
imageVector = column.type.icon(),
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(8.dp))
Text(
text = column.type.title(),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (canClose) {
IconButton(onClick = onClose, modifier = Modifier.size(28.dp)) {
Icon(
Icons.Default.Close,
contentDescription = "Close column",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
fun DeckColumnType.icon(): ImageVector =
when (this) {
DeckColumnType.HomeFeed -> Icons.Default.Home
DeckColumnType.Notifications -> Icons.Default.Notifications
DeckColumnType.Messages -> Icons.Default.Email
DeckColumnType.Search -> Icons.Default.Search
DeckColumnType.Reads -> Icons.AutoMirrored.Filled.Article
DeckColumnType.Bookmarks -> com.vitorpamplona.amethyst.commons.icons.Bookmark
DeckColumnType.GlobalFeed -> Icons.Default.Public
DeckColumnType.MyProfile -> Icons.Default.Person
DeckColumnType.Chess -> Icons.Default.Extension
DeckColumnType.Settings -> Icons.Default.Settings
is DeckColumnType.Profile -> Icons.Default.Person
is DeckColumnType.Thread -> Icons.AutoMirrored.Filled.Article
is DeckColumnType.Hashtag -> Icons.Default.Tag
}
@@ -0,0 +1,390 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui.deck
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.desktop.DesktopScreen
import com.vitorpamplona.amethyst.desktop.RelaySettingsScreen
import com.vitorpamplona.amethyst.desktop.account.AccountManager
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.chess.ChessScreen
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode
import com.vitorpamplona.amethyst.desktop.ui.BookmarksScreen
import com.vitorpamplona.amethyst.desktop.ui.FeedScreen
import com.vitorpamplona.amethyst.desktop.ui.NotificationsScreen
import com.vitorpamplona.amethyst.desktop.ui.ReadsScreen
import com.vitorpamplona.amethyst.desktop.ui.SearchScreen
import com.vitorpamplona.amethyst.desktop.ui.ThreadScreen
import com.vitorpamplona.amethyst.desktop.ui.UserProfileScreen
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
import com.vitorpamplona.amethyst.desktop.ui.chats.DesktopMessagesScreen
import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
class ColumnNavigationState {
private val _stack = MutableStateFlow<List<DesktopScreen>>(emptyList())
val stack: kotlinx.coroutines.flow.StateFlow<List<DesktopScreen>> = _stack.asStateFlow()
fun push(screen: DesktopScreen) {
_stack.value = _stack.value + screen
}
fun pop(): Boolean {
if (_stack.value.isEmpty()) return false
_stack.value = _stack.value.dropLast(1)
return true
}
fun clear() {
_stack.value = emptyList()
}
}
@Composable
fun DeckColumnContainer(
column: DeckColumn,
canClose: Boolean,
onClose: () -> Unit,
onDoubleClickHeader: () -> Unit = {},
relayManager: DesktopRelayConnectionManager,
localCache: DesktopLocalCache,
accountManager: AccountManager,
account: AccountState.LoggedIn,
nwcConnection: Nip47URINorm?,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
appScope: CoroutineScope,
onShowComposeDialog: () -> Unit,
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
onZapFeedback: (ZapFeedback) -> Unit,
modifier: Modifier = Modifier,
) {
val navState = remember(column.id) { ColumnNavigationState() }
val navStack by navState.stack.collectAsState()
val currentOverlay = navStack.lastOrNull()
Column(
modifier =
modifier
.width(column.width.dp)
.fillMaxHeight(),
) {
ColumnHeader(
column = column,
canClose = canClose,
hasBackStack = navStack.isNotEmpty(),
onBack = { navState.pop() },
onClose = onClose,
onDoubleClick = onDoubleClickHeader,
)
HorizontalDivider()
Box(
modifier = Modifier.fillMaxSize().padding(12.dp),
) {
if (currentOverlay != null) {
OverlayContent(
screen = currentOverlay,
relayManager = relayManager,
localCache = localCache,
account = account,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
onZapFeedback = onZapFeedback,
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
onBack = { navState.pop() },
)
} else {
RootContent(
columnType = column.type,
relayManager = relayManager,
localCache = localCache,
accountManager = accountManager,
account = account,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
appScope = appScope,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
onZapFeedback = onZapFeedback,
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
)
}
}
}
}
@Composable
internal fun RootContent(
columnType: DeckColumnType,
relayManager: DesktopRelayConnectionManager,
localCache: DesktopLocalCache,
accountManager: AccountManager,
account: AccountState.LoggedIn,
nwcConnection: Nip47URINorm?,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
appScope: CoroutineScope,
onShowComposeDialog: () -> Unit,
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
onZapFeedback: (ZapFeedback) -> Unit,
onNavigateToProfile: (String) -> Unit,
onNavigateToThread: (String) -> Unit,
) {
val scope = rememberCoroutineScope()
when (columnType) {
DeckColumnType.HomeFeed -> {
FeedScreen(
relayManager = relayManager,
localCache = localCache,
account = account,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
initialFeedMode = FeedMode.FOLLOWING,
onCompose = onShowComposeDialog,
onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
onZapFeedback = onZapFeedback,
)
}
DeckColumnType.Notifications -> {
NotificationsScreen(relayManager, account, subscriptionsCoordinator)
}
DeckColumnType.Messages -> {
val dmSendTracker =
remember(relayManager) {
DmSendTracker(relayManager.client)
}
val iAccount =
remember(account, localCache, relayManager, dmSendTracker) {
DesktopIAccount(account, localCache, relayManager, dmSendTracker, appScope)
}
DesktopMessagesScreen(
account = iAccount,
cacheProvider = localCache,
relayManager = relayManager,
localCache = localCache,
onNavigateToProfile = onNavigateToProfile,
)
}
DeckColumnType.Search -> {
SearchScreen(
localCache = localCache,
relayManager = relayManager,
subscriptionsCoordinator = subscriptionsCoordinator,
onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
)
}
DeckColumnType.Reads -> {
ReadsScreen(
relayManager = relayManager,
localCache = localCache,
account = account,
onNavigateToProfile = onNavigateToProfile,
onNavigateToArticle = onNavigateToThread,
)
}
DeckColumnType.Bookmarks -> {
BookmarksScreen(
relayManager = relayManager,
localCache = localCache,
account = account,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
onZapFeedback = onZapFeedback,
)
}
DeckColumnType.GlobalFeed -> {
FeedScreen(
relayManager = relayManager,
localCache = localCache,
account = account,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
initialFeedMode = FeedMode.GLOBAL,
onCompose = onShowComposeDialog,
onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
onZapFeedback = onZapFeedback,
)
}
DeckColumnType.MyProfile -> {
UserProfileScreen(
pubKeyHex = account.pubKeyHex,
relayManager = relayManager,
localCache = localCache,
account = account,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
onBack = {},
onCompose = onShowComposeDialog,
onNavigateToProfile = onNavigateToProfile,
onZapFeedback = onZapFeedback,
)
}
DeckColumnType.Chess -> {
ChessScreen(
relayManager = relayManager,
account = account,
onBack = {},
compactMode = true,
)
}
DeckColumnType.Settings -> {
RelaySettingsScreen(relayManager, account, accountManager)
}
is DeckColumnType.Profile -> {
UserProfileScreen(
pubKeyHex = columnType.pubKeyHex,
relayManager = relayManager,
localCache = localCache,
account = account,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
onBack = {},
onCompose = onShowComposeDialog,
onNavigateToProfile = onNavigateToProfile,
onZapFeedback = onZapFeedback,
)
}
is DeckColumnType.Thread -> {
ThreadScreen(
noteId = columnType.noteId,
relayManager = relayManager,
localCache = localCache,
account = account,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
onBack = {},
onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
onZapFeedback = onZapFeedback,
onReply = onShowReplyDialog,
)
}
is DeckColumnType.Hashtag -> {
SearchScreen(
localCache = localCache,
relayManager = relayManager,
subscriptionsCoordinator = subscriptionsCoordinator,
initialQuery = "#${columnType.tag}",
onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
)
}
}
}
@Composable
internal fun OverlayContent(
screen: DesktopScreen,
relayManager: DesktopRelayConnectionManager,
localCache: DesktopLocalCache,
account: AccountState.LoggedIn,
nwcConnection: Nip47URINorm?,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
onShowComposeDialog: () -> Unit,
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
onZapFeedback: (ZapFeedback) -> Unit,
onNavigateToProfile: (String) -> Unit,
onNavigateToThread: (String) -> Unit,
onBack: () -> Unit,
) {
when (screen) {
is DesktopScreen.UserProfile -> {
UserProfileScreen(
pubKeyHex = screen.pubKeyHex,
relayManager = relayManager,
localCache = localCache,
account = account,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
onBack = onBack,
onCompose = onShowComposeDialog,
onNavigateToProfile = onNavigateToProfile,
onZapFeedback = onZapFeedback,
)
}
is DesktopScreen.Thread -> {
ThreadScreen(
noteId = screen.noteId,
relayManager = relayManager,
localCache = localCache,
account = account,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
onBack = onBack,
onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
onZapFeedback = onZapFeedback,
onReply = onShowReplyDialog,
)
}
else -> {
androidx.compose.material3.Text(
"Unsupported screen type",
style = androidx.compose.material3.MaterialTheme.typography.bodyMedium,
color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -0,0 +1,97 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui.deck
import java.util.UUID
sealed class DeckColumnType {
object HomeFeed : DeckColumnType()
object Notifications : DeckColumnType()
object Messages : DeckColumnType()
object Search : DeckColumnType()
object Reads : DeckColumnType()
object Bookmarks : DeckColumnType()
object GlobalFeed : DeckColumnType()
object MyProfile : DeckColumnType()
object Chess : DeckColumnType()
object Settings : DeckColumnType()
data class Profile(
val pubKeyHex: String,
) : DeckColumnType()
data class Thread(
val noteId: String,
) : DeckColumnType()
data class Hashtag(
val tag: String,
) : DeckColumnType()
fun title(): String =
when (this) {
HomeFeed -> "Home"
Notifications -> "Notifications"
Messages -> "Messages"
Search -> "Search"
Reads -> "Reads"
Bookmarks -> "Bookmarks"
GlobalFeed -> "Global"
MyProfile -> "Profile"
Chess -> "Chess"
Settings -> "Settings"
is Profile -> "Profile"
is Thread -> "Thread"
is Hashtag -> "#$tag"
}
fun typeKey(): String =
when (this) {
HomeFeed -> "home"
Notifications -> "notifications"
Messages -> "messages"
Search -> "search"
Reads -> "reads"
Bookmarks -> "bookmarks"
GlobalFeed -> "global"
MyProfile -> "my_profile"
Chess -> "chess"
Settings -> "settings"
is Profile -> "profile"
is Thread -> "thread"
is Hashtag -> "hashtag"
}
}
data class DeckColumn(
val id: String = UUID.randomUUID().toString(),
val type: DeckColumnType,
val width: Float = 400f,
)
@@ -0,0 +1,144 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui.deck
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.PointerIcon
import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.desktop.account.AccountManager
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm
import kotlinx.coroutines.CoroutineScope
import java.awt.Cursor
@Composable
fun DeckLayout(
deckState: DeckState,
relayManager: DesktopRelayConnectionManager,
localCache: DesktopLocalCache,
accountManager: AccountManager,
account: AccountState.LoggedIn,
nwcConnection: Nip47URINorm?,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
appScope: CoroutineScope,
onShowComposeDialog: () -> Unit,
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
onZapFeedback: (ZapFeedback) -> Unit,
modifier: Modifier = Modifier,
) {
val columns by deckState.columns.collectAsState()
val density = LocalDensity.current
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
val availableWidthDp = with(density) { constraints.maxWidth.toDp().value }
deckState.setAvailableWidth(availableWidthDp)
// Auto-fit columns on first composition or when available width changes significantly
LaunchedEffect(availableWidthDp, columns.size) {
val dividers = (columns.size - 1) * DeckState.DIVIDER_WIDTH
val totalColumnWidth = columns.sumOf { it.width.toDouble() }.toFloat()
val diff = kotlin.math.abs(totalColumnWidth + dividers - availableWidthDp)
if (diff > 20f && columns.isNotEmpty()) {
deckState.fitColumnsToWidth(availableWidthDp)
}
}
Row(
modifier =
Modifier
.fillMaxSize()
.horizontalScroll(rememberScrollState()),
) {
columns.forEachIndexed { index, column ->
if (index > 0) {
DraggableDivider(
onDrag = { delta ->
val leftCol = columns[index - 1]
val rightCol = columns[index]
deckState.resizePair(leftCol.id, rightCol.id, delta, availableWidthDp)
},
)
}
DeckColumnContainer(
column = column,
canClose = columns.size > 1,
onClose = { deckState.removeColumn(column.id) },
onDoubleClickHeader = { deckState.expandColumn(column.id, availableWidthDp) },
relayManager = relayManager,
localCache = localCache,
accountManager = accountManager,
account = account,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
appScope = appScope,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
onZapFeedback = onZapFeedback,
)
}
}
}
}
@Composable
private fun DraggableDivider(onDrag: (Float) -> Unit) {
Box(
modifier =
Modifier
.width(12.dp)
.fillMaxHeight()
.pointerHoverIcon(PointerIcon(Cursor(Cursor.E_RESIZE_CURSOR)))
.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
change.consume()
onDrag(dragAmount.x / density)
}
},
contentAlignment = androidx.compose.ui.Alignment.Center,
) {
VerticalDivider(
color = MaterialTheme.colorScheme.outlineVariant,
)
}
}
@@ -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,295 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui.deck
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.desktop.DesktopPreferences
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class DeckState(
private val saveScope: CoroutineScope,
) {
private val _columns = MutableStateFlow(DEFAULT_COLUMNS)
val columns: StateFlow<List<DeckColumn>> = _columns.asStateFlow()
private val _focusedColumnIndex = MutableStateFlow(0)
val focusedColumnIndex: StateFlow<Int> = _focusedColumnIndex.asStateFlow()
@Volatile
private var lastKnownWidth: Float = 0f
private var saveJob: Job? = null
fun setAvailableWidth(width: Float) {
lastKnownWidth = width
}
fun addColumn(
type: DeckColumnType,
afterIndex: Int? = null,
) {
val col = DeckColumn(type = type)
_columns.update { current ->
if (afterIndex != null && afterIndex < current.size) {
current.toMutableList().apply { add(afterIndex + 1, col) }
} else {
current + col
}
}
// Auto-fit all columns to available width when known
val width = lastKnownWidth
if (width > 0f) {
fitColumnsToWidth(width)
} else {
scheduleSave()
}
}
fun hasColumnOfType(type: DeckColumnType): Boolean = _columns.value.any { it.type == type }
fun focusExistingColumn(type: DeckColumnType) {
val idx = _columns.value.indexOfFirst { it.type == type }
if (idx >= 0) focusColumn(idx)
}
fun removeColumn(id: String) {
_columns.update { current ->
if (current.size <= 1) return
val removed = current.find { it.id == id } ?: return
val remaining = current.filter { it.id != id }
val extra = removed.width / remaining.size
val result =
remaining.map {
it.copy(width = (it.width + extra).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH))
}
// Fix gaps: if total is less than available, redistribute remainder
val width = lastKnownWidth
if (width > 0f) {
val dividers = (result.size - 1) * DIVIDER_WIDTH
val totalUsed = result.sumOf { it.width.toDouble() }.toFloat()
val deficit = width - dividers - totalUsed
if (deficit > 1f) {
val perColumn = deficit / result.size
return@update result.map {
it.copy(width = (it.width + perColumn).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH))
}
}
}
result
}
_focusedColumnIndex.update { idx ->
idx.coerceAtMost(_columns.value.size - 1)
}
scheduleSave()
}
fun moveColumn(
fromIndex: Int,
toIndex: Int,
) {
_columns.update { current ->
if (fromIndex !in current.indices || toIndex !in current.indices) return
current.toMutableList().apply {
val item = removeAt(fromIndex)
add(toIndex, item)
}
}
scheduleSave()
}
fun updateColumnWidth(
id: String,
width: Float,
) {
_columns.update { current ->
current.map {
if (it.id == id) it.copy(width = width.coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)) else it
}
}
scheduleSave()
}
fun expandColumn(
id: String,
availableWidth: Float,
) {
_columns.update { cols ->
if (cols.find { it.id == id } == null) return
val othersMin = (cols.size - 1) * MIN_COLUMN_WIDTH
val dividerWidth = (cols.size - 1) * DIVIDER_WIDTH
val maxForTarget = (availableWidth - othersMin - dividerWidth).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)
cols.map {
if (it.id == id) {
it.copy(width = maxForTarget)
} else {
it.copy(width = MIN_COLUMN_WIDTH)
}
}
}
scheduleSave()
}
fun resizePair(
leftId: String,
rightId: String,
delta: Float,
availableWidth: Float,
) {
_columns.update { cols ->
val left = cols.find { it.id == leftId } ?: return
val right = cols.find { it.id == rightId } ?: return
val otherWidth = cols.filter { it.id != leftId && it.id != rightId }.sumOf { it.width.toDouble() }.toFloat()
val dividerWidth = (cols.size - 1) * DIVIDER_WIDTH
val maxPairWidth = availableWidth - otherWidth - dividerWidth
var newLeft = (left.width + delta).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)
var newRight = (right.width - delta).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)
if (newLeft + newRight > maxPairWidth) {
if (delta > 0) {
newLeft = (maxPairWidth - newRight).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)
} else {
newRight = (maxPairWidth - newLeft).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)
}
}
cols.map {
when (it.id) {
leftId -> it.copy(width = newLeft)
rightId -> it.copy(width = newRight)
else -> it
}
}
}
scheduleSave()
}
fun fitColumnsToWidth(availableWidth: Float) {
_columns.update { cols ->
if (cols.isEmpty()) return
val dividers = (cols.size - 1) * DIVIDER_WIDTH
val usable = availableWidth - dividers
val perColumn = (usable / cols.size).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)
cols.map { it.copy(width = perColumn) }
}
scheduleSave()
}
fun focusColumn(index: Int) {
if (index in _columns.value.indices) {
_focusedColumnIndex.value = index
}
}
private fun scheduleSave() {
saveJob?.cancel()
saveJob =
saveScope.launch {
delay(SAVE_DEBOUNCE_MS)
save()
}
}
fun save() {
try {
val data =
_columns.value.map { col ->
mapOf(
"id" to col.id,
"type" to col.type.typeKey(),
"width" to col.width,
"param" to
when (col.type) {
is DeckColumnType.Profile -> col.type.pubKeyHex
is DeckColumnType.Thread -> col.type.noteId
is DeckColumnType.Hashtag -> col.type.tag
else -> null
},
)
}
DesktopPreferences.deckColumns = mapper.writeValueAsString(data)
} catch (e: Exception) {
println("DeckState: failed to save columns: ${e.message}")
}
}
fun load() {
try {
val json = DesktopPreferences.deckColumns
if (json.isBlank()) return
val data: List<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 (e: Exception) {
println("DeckState: failed to load columns: ${e.message}")
}
}
companion object {
const val MIN_COLUMN_WIDTH = 300f
const val MAX_COLUMN_WIDTH = 800f
const val DIVIDER_WIDTH = 12f
private const val SAVE_DEBOUNCE_MS = 500L
val DEFAULT_COLUMNS =
listOf(
DeckColumn(id = "default-home", type = DeckColumnType.HomeFeed),
DeckColumn(id = "default-notifications", type = DeckColumnType.Notifications),
DeckColumn(id = "default-messages", type = DeckColumnType.Messages),
)
private val mapper = jacksonObjectMapper()
private fun parseColumnType(entry: Map<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
"chess" -> DeckColumnType.Chess
"settings" -> DeckColumnType.Settings
"profile" -> param?.let { DeckColumnType.Profile(it) }
"thread" -> param?.let { DeckColumnType.Thread(it) }
"hashtag" -> param?.let { DeckColumnType.Hashtag(it) }
else -> null
}
}
}
}
@@ -0,0 +1,233 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui.deck
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Article
import androidx.compose.material.icons.filled.Bookmark
import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.Extension
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.desktop.DesktopScreen
import com.vitorpamplona.amethyst.desktop.account.AccountManager
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm
import kotlinx.coroutines.CoroutineScope
private data class NavItem(
val type: DeckColumnType,
val icon: ImageVector,
val label: String,
)
private val navItems =
listOf(
NavItem(DeckColumnType.HomeFeed, Icons.Default.Home, "Home"),
NavItem(DeckColumnType.Reads, Icons.AutoMirrored.Filled.Article, "Reads"),
NavItem(DeckColumnType.Search, Icons.Default.Search, "Search"),
NavItem(DeckColumnType.Bookmarks, Icons.Default.Bookmark, "Bookmarks"),
NavItem(DeckColumnType.Messages, Icons.Default.Email, "Messages"),
NavItem(DeckColumnType.Notifications, Icons.Default.Notifications, "Notifications"),
NavItem(DeckColumnType.MyProfile, Icons.Default.Person, "Profile"),
NavItem(DeckColumnType.Chess, Icons.Default.Extension, "Chess"),
NavItem(DeckColumnType.Settings, Icons.Default.Settings, "Settings"),
)
@Composable
fun SinglePaneLayout(
relayManager: DesktopRelayConnectionManager,
localCache: DesktopLocalCache,
accountManager: AccountManager,
account: AccountState.LoggedIn,
nwcConnection: Nip47URINorm?,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
appScope: CoroutineScope,
onShowComposeDialog: () -> Unit,
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
onZapFeedback: (ZapFeedback) -> Unit,
modifier: Modifier = Modifier,
) {
var currentColumnType by remember { mutableStateOf<DeckColumnType>(DeckColumnType.HomeFeed) }
val navState = remember { ColumnNavigationState() }
val navStack by navState.stack.collectAsState()
val currentOverlay = navStack.lastOrNull()
Row(modifier = modifier.fillMaxSize()) {
NavigationRail(
modifier = Modifier.width(80.dp).fillMaxHeight(),
containerColor = MaterialTheme.colorScheme.surfaceVariant,
) {
navItems.forEach { item ->
NavigationRailItem(
selected = currentColumnType == item.type && navStack.isEmpty(),
onClick = {
currentColumnType = item.type
navState.clear()
},
icon = {
Icon(
item.icon,
contentDescription = item.label,
modifier = Modifier.size(22.dp),
)
},
label = {
Text(
item.label,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
)
}
}
VerticalDivider()
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
// Show header with back button when navigated into overlay
if (navStack.isNotEmpty()) {
SinglePaneHeader(
title =
when (currentOverlay) {
is DesktopScreen.UserProfile -> "Profile"
is DesktopScreen.Thread -> "Thread"
else -> currentColumnType.title()
},
onBack = { navState.pop() },
)
HorizontalDivider()
}
Box(
modifier = Modifier.fillMaxSize().padding(12.dp),
) {
if (currentOverlay != null) {
OverlayContent(
screen = currentOverlay,
relayManager = relayManager,
localCache = localCache,
account = account,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
onZapFeedback = onZapFeedback,
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
onBack = { navState.pop() },
)
} else {
RootContent(
columnType = currentColumnType,
relayManager = relayManager,
localCache = localCache,
accountManager = accountManager,
account = account,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
appScope = appScope,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
onZapFeedback = onZapFeedback,
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
)
}
}
}
}
}
@Composable
private fun SinglePaneHeader(
title: String,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier =
modifier
.fillMaxWidth()
.height(40.dp)
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack, modifier = Modifier.size(28.dp)) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.width(8.dp))
Text(
text = title,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}