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:
@@ -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
|
||||
|
||||
+162
@@ -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")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+129
@@ -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
|
||||
}
|
||||
+365
@@ -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 -> {}
|
||||
}
|
||||
}
|
||||
+93
@@ -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,
|
||||
)
|
||||
+122
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+89
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+163
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user