diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index df890e6b7..a82aa523e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -29,7 +29,9 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState @@ -1319,213 +1321,232 @@ fun RelaySettingsScreen( accountManager.loadNwcConnection() } - Column( - modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()), + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.TopCenter, ) { - Text( - "Settings", - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onBackground, - ) - - Spacer(Modifier.height(24.dp)) - - // Wallet Connect Section - Text( - "Wallet Connect (NWC)", - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, - ) - Spacer(Modifier.height(8.dp)) - - Text( - "Connect a Lightning wallet to enable zaps. Get a connection string from Alby, Mutiny, or other NWC-compatible wallets.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Spacer(Modifier.height(12.dp)) - - if (nwcConnection != null) { + Column( + modifier = + Modifier + .fillMaxSize() + .widthIn(max = 720.dp) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 12.dp), + ) { Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { - Column { - Text( - "Wallet Connected", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - ) - Text( - "Relay: ${nwcConnection!!.relayUri.url}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - OutlinedButton( - onClick = { accountManager.clearNwcConnection() }, - colors = - ButtonDefaults.outlinedButtonColors( - contentColor = MaterialTheme.colorScheme.error, - ), + Text( + "Settings", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + } + + Spacer(Modifier.height(16.dp)) + + // Wallet Connect Section + Text( + "Wallet Connect (NWC)", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + Spacer(Modifier.height(8.dp)) + + Text( + "Connect a Lightning wallet to enable zaps. Get a connection string from Alby, Mutiny, or other NWC-compatible wallets.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(12.dp)) + + if (nwcConnection != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - Text("Disconnect") + Column { + Text( + "Wallet Connected", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + Text( + "Relay: ${nwcConnection!!.relayUri.url}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + OutlinedButton( + onClick = { accountManager.clearNwcConnection() }, + colors = + ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Text("Disconnect") + } + } + } else { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = nwcInput, + onValueChange = { + nwcInput = it + nwcError = null + }, + label = { Text("NWC Connection String") }, + placeholder = { Text("nostr+walletconnect://...") }, + modifier = Modifier.weight(1f), + singleLine = true, + isError = nwcError != null, + supportingText = nwcError?.let { { Text(it, color = MaterialTheme.colorScheme.error) } }, + ) + Button( + onClick = { + val result = accountManager.setNwcConnection(nwcInput) + result.fold( + onSuccess = { nwcInput = "" }, + onFailure = { nwcError = it.message ?: "Invalid connection string" }, + ) + }, + enabled = nwcInput.isNotBlank(), + ) { + Text("Connect") + } } } - } else { + + Spacer(Modifier.height(24.dp)) + HorizontalDivider() + Spacer(Modifier.height(24.dp)) + + // Media Server Settings + MediaServerSettings( + initialServers = DesktopPreferences.blossomServers, + onServersChanged = { DesktopPreferences.blossomServers = it }, + ) + Spacer(Modifier.height(24.dp)) + HorizontalDivider() + Spacer(Modifier.height(24.dp)) + + // Tor Settings + com.vitorpamplona.amethyst.desktop.ui.tor.TorSettingsSection( + torStatus = torStatus, + currentSettings = torSettings, + onSettingsChanged = onTorSettingsChanged, + ) + Spacer(Modifier.height(24.dp)) + HorizontalDivider() + Spacer(Modifier.height(24.dp)) + + // Developer Settings Section (only in debug mode) + if (DebugConfig.isDebugMode) { + com.vitorpamplona.amethyst.desktop.ui + .DevSettingsSection(account = account) + Spacer(Modifier.height(24.dp)) + HorizontalDivider() + Spacer(Modifier.height(24.dp)) + } + + Text( + "Relay Settings", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + Spacer(Modifier.height(8.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + "${connectedRelays.size} of ${relayStatuses.size} relays connected", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + ) + IconButton(onClick = { relayManager.connect() }) { + Icon( + MaterialSymbols.Refresh, + contentDescription = "Reconnect", + tint = MaterialTheme.colorScheme.primary, + ) + } + } + + Spacer(Modifier.height(16.dp)) + Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { OutlinedTextField( - value = nwcInput, - onValueChange = { - nwcInput = it - nwcError = null - }, - label = { Text("NWC Connection String") }, - placeholder = { Text("nostr+walletconnect://...") }, + value = newRelayUrl, + onValueChange = { newRelayUrl = it }, + label = { Text("Add relay") }, + placeholder = { Text("wss://relay.example.com") }, modifier = Modifier.weight(1f), singleLine = true, - isError = nwcError != null, - supportingText = nwcError?.let { { Text(it, color = MaterialTheme.colorScheme.error) } }, ) Button( onClick = { - val result = accountManager.setNwcConnection(nwcInput) - result.fold( - onSuccess = { nwcInput = "" }, - onFailure = { nwcError = it.message ?: "Invalid connection string" }, - ) + if (newRelayUrl.isNotBlank()) { + relayManager.addRelay(newRelayUrl) + newRelayUrl = "" + } }, - enabled = nwcInput.isNotBlank(), + enabled = newRelayUrl.isNotBlank(), ) { - Text("Connect") + Text("Add") } } - } - Spacer(Modifier.height(24.dp)) - HorizontalDivider() - Spacer(Modifier.height(24.dp)) + Spacer(Modifier.height(16.dp)) - // Media Server Settings - MediaServerSettings( - initialServers = DesktopPreferences.blossomServers, - onServersChanged = { DesktopPreferences.blossomServers = it }, - ) - Spacer(Modifier.height(24.dp)) - HorizontalDivider() - Spacer(Modifier.height(24.dp)) - - // Tor Settings - com.vitorpamplona.amethyst.desktop.ui.tor.TorSettingsSection( - torStatus = torStatus, - currentSettings = torSettings, - onSettingsChanged = onTorSettingsChanged, - ) - Spacer(Modifier.height(24.dp)) - HorizontalDivider() - Spacer(Modifier.height(24.dp)) - - // Developer Settings Section (only in debug mode) - if (DebugConfig.isDebugMode) { - com.vitorpamplona.amethyst.desktop.ui - .DevSettingsSection(account = account) - Spacer(Modifier.height(24.dp)) - HorizontalDivider() - Spacer(Modifier.height(24.dp)) - } - - Text( - "Relay Settings", - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, - ) - Spacer(Modifier.height(8.dp)) - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - "${connectedRelays.size} of ${relayStatuses.size} relays connected", - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium, - ) - IconButton(onClick = { relayManager.connect() }) { - Icon( - MaterialSymbols.Refresh, - contentDescription = "Reconnect", - tint = MaterialTheme.colorScheme.primary, - ) - } - } - - Spacer(Modifier.height(16.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedTextField( - value = newRelayUrl, - onValueChange = { newRelayUrl = it }, - label = { Text("Add relay") }, - placeholder = { Text("wss://relay.example.com") }, + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.weight(1f), - singleLine = true, - ) - Button( - onClick = { - if (newRelayUrl.isNotBlank()) { - relayManager.addRelay(newRelayUrl) - newRelayUrl = "" - } - }, - enabled = newRelayUrl.isNotBlank(), ) { - Text("Add") + items(relayStatuses.values.toList(), key = { it.url.url }) { status -> + RelayStatusCard( + status = status, + onRemove = { relayManager.removeRelay(status.url) }, + ) + } } - } - Spacer(Modifier.height(16.dp)) + Spacer(Modifier.height(16.dp)) - LazyColumn( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.weight(1f), - ) { - items(relayStatuses.values.toList(), key = { it.url.url }) { status -> - RelayStatusCard( - status = status, - onRemove = { relayManager.removeRelay(status.url) }, - ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { relayManager.addDefaultRelays() }) { + Text("Reset to Defaults") + } } - } - Spacer(Modifier.height(16.dp)) + Spacer(Modifier.height(16.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedButton(onClick = { relayManager.addDefaultRelays() }) { - Text("Reset to Defaults") + val logoutScope = rememberCoroutineScope() + OutlinedButton( + onClick = { logoutScope.launch { accountManager.logout(deleteKey = true) } }, + colors = + ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Text("Logout") } } - - Spacer(Modifier.height(16.dp)) - - val logoutScope = rememberCoroutineScope() - OutlinedButton( - onClick = { logoutScope.launch { accountManager.logout(deleteKey = true) } }, - colors = - ButtonDefaults.outlinedButtonColors( - contentColor = MaterialTheme.colorScheme.error, - ), - ) { - Text("Logout") - } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt index e6bd5355a..8c418e309 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -247,12 +248,13 @@ fun BookmarksScreen( val currentEvents = if (selectedTab == BookmarkTab.PUBLIC) publicEvents else privateEvents val currentBookmarkIds = if (selectedTab == BookmarkTab.PUBLIC) publicBookmarkIds else privateBookmarkIds - Column(modifier = Modifier.fillMaxSize()) { + ReadingColumn { // Header with tabs Row( modifier = Modifier .fillMaxWidth() + .heightIn(min = 48.dp) .padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt index 0721982dd..5668a93a6 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt @@ -26,14 +26,14 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon @@ -67,11 +67,12 @@ fun DraftsScreen( val scope = rememberCoroutineScope() var deleteTarget by remember { mutableStateOf(null) } - Column(modifier = Modifier.fillMaxSize()) { + ReadingColumn { Row( modifier = Modifier .fillMaxWidth() + .heightIn(min = 48.dp) .padding(horizontal = 12.dp, vertical = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, @@ -81,9 +82,15 @@ fun DraftsScreen( style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onBackground, ) - Button(onClick = { onOpenEditor(null) }) { - Icon(MaterialSymbols.Add, contentDescription = null) - Text("New Draft", modifier = Modifier.padding(start = 4.dp)) + // Convert "New Draft" button to an icon for consistency with other + // screens' tabs-first + icon-actions header pattern. + IconButton(onClick = { onOpenEditor(null) }, modifier = Modifier.size(32.dp)) { + Icon( + MaterialSymbols.Add, + contentDescription = "New Draft", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 90466bb21..f8542c338 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -489,7 +489,7 @@ fun FeedScreen( } Box(modifier = Modifier.fillMaxSize()) { - Column(modifier = Modifier.fillMaxSize()) { + ReadingColumn { // Header with compose button FeedHeader( feedMode = feedMode, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt index ee62d0b96..6a7395e89 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt @@ -25,7 +25,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -238,7 +237,7 @@ fun NotificationsScreen( } } - Column(modifier = Modifier.fillMaxSize()) { + ReadingColumn { FeedHeader( title = "Notifications", connectedRelayCount = connectedRelays.size, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadingColumn.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadingColumn.kt new file mode 100644 index 000000000..7f6bb82f5 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadingColumn.kt @@ -0,0 +1,67 @@ +/* + * 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 + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.widthIn +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Maximum reading width for single-pane content screens. Matches the + * comfortable column width used by Twitter / Mastodon / Threads on desktop — + * wider than a book column (which tops out around 600 dp), narrower than a + * full-window feed, so cards don't stretch disproportionately on 4K displays. + */ +val DefaultReadingWidth: Dp = 720.dp + +/** + * A top-level content scaffold that caps width and centers its column on wide + * displays. Each feed / list / profile screen wraps its contents in this so + * cards maintain a consistent proportion across the whole app. + * + * Not used by: + * - Messages (two-pane layout with its own sizing) + * - Article Reader (has its own narrower reading-width logic) + * - Editor / Chess / Relay Dashboard (rely on full width for tools / boards) + */ +@Composable +fun ReadingColumn( + modifier: Modifier = Modifier, + maxWidth: Dp = DefaultReadingWidth, + content: @Composable ColumnScope.() -> Unit, +) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.TopCenter, + ) { + Column( + modifier = modifier.fillMaxSize().widthIn(max = maxWidth), + content = content, + ) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt index e55af2e42..9b015f0b9 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt @@ -26,7 +26,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -288,7 +287,7 @@ fun ReadsScreen( } } - Column(modifier = Modifier.fillMaxSize()) { + ReadingColumn { // Header — Messages-style: tabs left, refresh right. The selected tab // (Following / Global) acts as the screen title, so no separate label. Row( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt index f9b812fd4..e980ccb2d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt @@ -34,9 +34,11 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape @@ -289,227 +291,236 @@ fun SearchScreen( focusRequester.requestFocus() } - Column( + androidx.compose.foundation.layout.Box( modifier = - modifier - .fillMaxSize() - .onPreviewKeyEvent { event -> - if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false - when (event.key) { - Key.Escape -> { - if (panelExpanded) { - state.togglePanel() - } else if (displayText.isNotEmpty()) { - state.clearSearch() - } - true - } - - else -> { - false - } - } - }, + androidx.compose.ui.Modifier + .fillMaxSize(), + contentAlignment = androidx.compose.ui.Alignment.TopCenter, ) { - // Progress bar at very top - AnimatedVisibility( - visible = isSearching, - enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(), - exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(), - ) { - LinearProgressIndicator( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.primary, - trackColor = MaterialTheme.colorScheme.surfaceVariant, - ) - } - - // Relay status banner - SearchSyncBanner( - relayStates = relayStates, - isSearching = isSearching, - ) - - // Title row - Row( + Column( modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - "Search", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onBackground, - ) - Text( - "${localCache.userCount()} users cached", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + modifier + .fillMaxSize() + .widthIn(max = DefaultReadingWidth) + .onPreviewKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + when (event.key) { + Key.Escape -> { + if (panelExpanded) { + state.togglePanel() + } else if (displayText.isNotEmpty()) { + state.clearSearch() + } + true + } - Spacer(Modifier.height(16.dp)) - - // Search bar with advanced toggle - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), + else -> { + false + } + } + }, ) { - OutlinedTextField( - value = textFieldValue, - onValueChange = { - textFieldValue = it - state.updateFromText(it.text) - }, - modifier = Modifier.weight(1f).focusRequester(focusRequester), - placeholder = { Text("Search notes, people, tags... or use operators") }, - leadingIcon = { + // Progress bar at very top + AnimatedVisibility( + visible = isSearching, + enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(), + exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(), + ) { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } + + // Relay status banner + SearchSyncBanner( + relayStates = relayStates, + isSearching = isSearching, + ) + + // Title row + Row( + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "Search", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + "${localCache.userCount()} users cached", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(Modifier.height(16.dp)) + + // Search bar with advanced toggle + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = textFieldValue, + onValueChange = { + textFieldValue = it + state.updateFromText(it.text) + }, + modifier = Modifier.weight(1f).focusRequester(focusRequester), + placeholder = { Text("Search notes, people, tags... or use operators") }, + leadingIcon = { + Icon( + MaterialSymbols.Search, + contentDescription = "Search", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + trailingIcon = { + if (displayText.isNotEmpty()) { + IconButton(onClick = { state.clearSearch() }) { + Icon( + MaterialSymbols.Clear, + contentDescription = "Clear", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + singleLine = true, + shape = RoundedCornerShape(12.dp), + ) + if (account != null && !account.isReadOnly) { + IconButton(onClick = { showRelayPicker = true }) { + Icon( + MaterialSymbols.Dns, + contentDescription = "Search Relays", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + IconButton(onClick = { state.togglePanel() }) { Icon( - MaterialSymbols.Search, - contentDescription = "Search", - tint = MaterialTheme.colorScheme.onSurfaceVariant, + MaterialSymbols.Tune, + contentDescription = "Advanced Search", + tint = + if (panelExpanded) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, ) - }, - trailingIcon = { - if (displayText.isNotEmpty()) { - IconButton(onClick = { state.clearSearch() }) { - Icon( - MaterialSymbols.Clear, - contentDescription = "Clear", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + } + } + + // Search relay picker dialog + if (showRelayPicker && account != null) { + val pickerRelays = + remember { + mutableStateListOf().also { + it.addAll(searchRelays) } } - }, - singleLine = true, - shape = RoundedCornerShape(12.dp), - ) - if (account != null && !account.isReadOnly) { - IconButton(onClick = { showRelayPicker = true }) { - Icon( - MaterialSymbols.Dns, - contentDescription = "Search Relays", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - IconButton(onClick = { state.togglePanel() }) { - Icon( - MaterialSymbols.Tune, - contentDescription = "Advanced Search", - tint = - if (panelExpanded) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, + AlertDialog( + onDismissRequest = { showRelayPicker = false }, + title = { Text("Search Relays") }, + text = { + SearchRelayEditor( + localRelays = pickerRelays, + signer = account.signer, + onPublish = { event -> + relayManager.broadcastToAll(event) + accountRelays?.consumePublishedEvent(event) + accountRelays?.setSearchRelays(pickerRelays.toSet()) + }, + ) + }, + confirmButton = { + TextButton(onClick = { showRelayPicker = false }) { + Text("Close") + } + }, ) } - } - // Search relay picker dialog - if (showRelayPicker && account != null) { - val pickerRelays = - remember { - mutableStateListOf().also { - it.addAll(searchRelays) + // Expandable advanced panel + AnimatedVisibility( + visible = panelExpanded, + enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(), + exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(), + ) { + AdvancedSearchPanel( + query = query, + onKindsChanged = { state.updateKinds(it) }, + onPseudoKindsChanged = { state.updatePseudoKinds(it) }, + onAuthorAdded = { state.addAuthor(it) }, + onAuthorRemoved = { state.removeAuthor(it) }, + onDateRangeChanged = { since, until -> state.updateDateRange(since, until) }, + onHashtagAdded = { state.addHashtag(it) }, + onHashtagRemoved = { state.removeHashtag(it) }, + onExcludeAdded = { state.addExcludeTerm(it) }, + onExcludeRemoved = { state.removeExcludeTerm(it) }, + onLanguageChanged = { state.updateLanguage(it) }, + onClear = { state.clearSearch() }, + modifier = Modifier.padding(top = 8.dp), + ) + } + + Spacer(Modifier.height(16.dp)) + + // Results + val hasAnyResults = + bech32Results.isNotEmpty() || peopleResults.isNotEmpty() || noteResults.isNotEmpty() + + if (bech32Results.isNotEmpty()) { + // Show bech32 results (exact lookup) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + "Direct lookup", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 4.dp), + ) + bech32Results.forEach { result -> + SearchResultCard( + result = result, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + onNavigateToHashtag = onNavigateToHashtag, + ) } } - AlertDialog( - onDismissRequest = { showRelayPicker = false }, - title = { Text("Search Relays") }, - text = { - SearchRelayEditor( - localRelays = pickerRelays, - signer = account.signer, - onPublish = { event -> - relayManager.broadcastToAll(event) - accountRelays?.consumePublishedEvent(event) - accountRelays?.setSearchRelays(pickerRelays.toSet()) - }, - ) - }, - confirmButton = { - TextButton(onClick = { showRelayPicker = false }) { - Text("Close") - } - }, - ) - } - - // Expandable advanced panel - AnimatedVisibility( - visible = panelExpanded, - enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(), - exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(), - ) { - AdvancedSearchPanel( - query = query, - onKindsChanged = { state.updateKinds(it) }, - onPseudoKindsChanged = { state.updatePseudoKinds(it) }, - onAuthorAdded = { state.addAuthor(it) }, - onAuthorRemoved = { state.removeAuthor(it) }, - onDateRangeChanged = { since, until -> state.updateDateRange(since, until) }, - onHashtagAdded = { state.addHashtag(it) }, - onHashtagRemoved = { state.removeHashtag(it) }, - onExcludeAdded = { state.addExcludeTerm(it) }, - onExcludeRemoved = { state.removeExcludeTerm(it) }, - onLanguageChanged = { state.updateLanguage(it) }, - onClear = { state.clearSearch() }, - modifier = Modifier.padding(top = 8.dp), - ) - } - - Spacer(Modifier.height(16.dp)) - - // Results - val hasAnyResults = - bech32Results.isNotEmpty() || peopleResults.isNotEmpty() || noteResults.isNotEmpty() - - if (bech32Results.isNotEmpty()) { - // Show bech32 results (exact lookup) - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + } else if (hasAnyResults) { + SearchResultsList( + state = state, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + localCache = localCache, + ) + } else if (!debouncedQuery.isEmpty && !isSearching) { Text( - "Direct lookup", - style = MaterialTheme.typography.labelMedium, + "No results found. Try broader terms or fewer filters.", color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(vertical = 4.dp), + style = MaterialTheme.typography.bodyMedium, + ) + } else if (!isSearching) { + // Empty state: show history + saved searches + operator hints + SearchEmptyState( + historyItems = historyItems, + savedSearches = savedSearches, + onLoadQuery = { query -> state.updateFromText(QuerySerializer.serialize(query)) }, + onDeleteSaved = { id -> SearchHistoryStore.deleteSavedSearch(id) }, + onClearHistory = { SearchHistoryStore.clearHistory() }, ) - bech32Results.forEach { result -> - SearchResultCard( - result = result, - onNavigateToProfile = onNavigateToProfile, - onNavigateToThread = onNavigateToThread, - onNavigateToHashtag = onNavigateToHashtag, - ) - } } - } else if (hasAnyResults) { - SearchResultsList( - state = state, - onNavigateToProfile = onNavigateToProfile, - onNavigateToThread = onNavigateToThread, - localCache = localCache, - ) - } else if (!debouncedQuery.isEmpty && !isSearching) { - Text( - "No results found. Try broader terms or fewer filters.", - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium, - ) - } else if (!isSearching) { - // Empty state: show history + saved searches + operator hints - SearchEmptyState( - historyItems = historyItems, - savedSearches = savedSearches, - onLoadQuery = { query -> state.updateFromText(QuerySerializer.serialize(query)) }, - onDeleteSaved = { id -> SearchHistoryStore.deleteSavedSearch(id) }, - onClearHistory = { SearchHistoryStore.clearHistory() }, - ) } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt index b3e855c01..b57685ec3 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt @@ -198,7 +198,7 @@ fun ThreadScreen( val replyNotes = threadNotes.filter { it.idHex != noteId } Box(modifier = Modifier.fillMaxSize()) { - Column(modifier = Modifier.fillMaxSize()) { + ReadingColumn { // Header — Messages-style: compact row with back + titleMedium Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index f922a8fed..dded5f020 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -36,6 +36,7 @@ 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.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState @@ -115,6 +116,7 @@ fun UserProfileScreen( nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, onBack: () -> Unit, + canGoBack: Boolean = false, onCompose: () -> Unit = {}, onNavigateToProfile: (String) -> Unit = {}, onNavigateToThread: (String) -> Unit = {}, @@ -436,489 +438,503 @@ fun UserProfileScreen( previousFirstVisibleItemScrollOffset = currentOffset } - Box(modifier = Modifier.fillMaxSize()) { - if (connectedRelays.isEmpty()) { - LoadingState("Connecting to relays...") - } else { - LazyColumn( - state = listState, - contentPadding = PaddingValues(horizontal = 12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxSize(), - ) { - // Broadcast banner - item(key = "broadcast") { - ProfileBroadcastBanner( - status = broadcastStatus, - onTap = { - if (broadcastStatus is ProfileBroadcastStatus.Success || - broadcastStatus is ProfileBroadcastStatus.Failed - ) { - broadcastStatus = ProfileBroadcastStatus.Idle - } - }, - ) - } + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { + Box(modifier = Modifier.fillMaxSize().widthIn(max = DefaultReadingWidth)) { + if (connectedRelays.isEmpty()) { + LoadingState("Connecting to relays...") + } else { + LazyColumn( + state = listState, + contentPadding = PaddingValues(horizontal = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxSize(), + ) { + // Broadcast banner + item(key = "broadcast") { + ProfileBroadcastBanner( + status = broadcastStatus, + onTap = { + if (broadcastStatus is ProfileBroadcastStatus.Success || + broadcastStatus is ProfileBroadcastStatus.Failed + ) { + broadcastStatus = ProfileBroadcastStatus.Idle + } + }, + ) + } - // Header — Messages-style: compact row, titleMedium title - item(key = "header") { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = onBack, modifier = Modifier.size(32.dp)) { - Icon( - MaterialSymbols.AutoMirrored.ArrowBack, - contentDescription = "Back", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(20.dp), + // Header — Messages-style: compact row, titleMedium title + item(key = "header") { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + // Back is shown only when stacked onto a nav stack (e.g. + // clicked a user in the feed). Top-level "My Profile" + // from the nav rail sets canGoBack = false so no arrow. + if (canGoBack) { + IconButton(onClick = onBack, modifier = Modifier.size(32.dp)) { + Icon( + MaterialSymbols.AutoMirrored.ArrowBack, + contentDescription = "Back", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + } + Spacer(Modifier.width(8.dp)) + } + Text( + "Profile", + style = MaterialTheme.typography.titleMedium, ) } - Spacer(Modifier.width(8.dp)) - Text( - "Profile", - style = MaterialTheme.typography.titleMedium, - ) - } - // Edit button for own profile - if (isOwnProfile && account.isReadOnly == false) { - OutlinedButton( - onClick = { - editingDisplayName = displayName ?: "" - showEditDialog = true - }, - ) { - Icon( - MaterialSymbols.Edit, - contentDescription = "Edit profile", - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier.width(8.dp)) - Text("Edit Profile") - } - } - - // Follow/Unfollow button for other profiles - if (account != null && !account.isReadOnly && pubKeyHex != account.pubKeyHex) { - Column(horizontalAlignment = Alignment.End) { - Button( + // Edit button for own profile + if (isOwnProfile && account.isReadOnly == false) { + OutlinedButton( onClick = { - scope.launch { - val currentStatus = followState.currentStatusOrNull() + editingDisplayName = displayName ?: "" + showEditDialog = true + }, + ) { + Icon( + MaterialSymbols.Edit, + contentDescription = "Edit profile", + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text("Edit Profile") + } + } - followState.setFollowLoading() - try { - val updatedEvent = - if (currentStatus?.isFollowing == true) { - unfollowUser(pubKeyHex, account, relayManager, myContactList) - } else { - followUser(pubKeyHex, account, relayManager, myContactList) - } + // Follow/Unfollow button for other profiles + if (account != null && !account.isReadOnly && pubKeyHex != account.pubKeyHex) { + Column(horizontalAlignment = Alignment.End) { + Button( + onClick = { + scope.launch { + val currentStatus = followState.currentStatusOrNull() - // Update both stored contact list and followState - myContactList = updatedEvent - followState.setFollowSuccess(updatedEvent, pubKeyHex) - } catch (e: Exception) { - e.printStackTrace() - followState.setFollowError(e.message ?: "Failed to update follow status", e) + followState.setFollowLoading() + try { + val updatedEvent = + if (currentStatus?.isFollowing == true) { + unfollowUser(pubKeyHex, account, relayManager, myContactList) + } else { + followUser(pubKeyHex, account, relayManager, myContactList) + } + + // Update both stored contact list and followState + myContactList = updatedEvent + followState.setFollowSuccess(updatedEvent, pubKeyHex) + } catch (e: Exception) { + e.printStackTrace() + followState.setFollowError(e.message ?: "Failed to update follow status", e) + } + } + }, + enabled = contactListLoaded && followState.state.value !is com.vitorpamplona.amethyst.commons.state.LoadingState.Loading, + ) { + val state = followState.state.collectAsState().value + val isFollowing = (state as? com.vitorpamplona.amethyst.commons.state.LoadingState.Success)?.data?.isFollowing ?: false + val isLoading = state is com.vitorpamplona.amethyst.commons.state.LoadingState.Loading + + when { + !contactListLoaded -> { + androidx.compose.material3.CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(Modifier.width(8.dp)) + Text("Loading...") + } + + isLoading -> { + androidx.compose.material3.CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(Modifier.width(8.dp)) + Text(if (isFollowing) "Unfollowing..." else "Following...") + } + + else -> { + Icon( + if (isFollowing) MaterialSymbols.PersonRemove else MaterialSymbols.PersonAdd, + contentDescription = if (isFollowing) "Unfollow" else "Follow", + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text(if (isFollowing) "Unfollow" else "Follow") } } - }, - enabled = contactListLoaded && followState.state.value !is com.vitorpamplona.amethyst.commons.state.LoadingState.Loading, - ) { - val state = followState.state.collectAsState().value - val isFollowing = (state as? com.vitorpamplona.amethyst.commons.state.LoadingState.Success)?.data?.isFollowing ?: false - val isLoading = state is com.vitorpamplona.amethyst.commons.state.LoadingState.Loading - - when { - !contactListLoaded -> { - androidx.compose.material3.CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary, - ) - Spacer(Modifier.width(8.dp)) - Text("Loading...") - } - - isLoading -> { - androidx.compose.material3.CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary, - ) - Spacer(Modifier.width(8.dp)) - Text(if (isFollowing) "Unfollowing..." else "Following...") - } - - else -> { - Icon( - if (isFollowing) MaterialSymbols.PersonRemove else MaterialSymbols.PersonAdd, - contentDescription = if (isFollowing) "Unfollow" else "Follow", - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier.width(8.dp)) - Text(if (isFollowing) "Unfollow" else "Follow") - } } - } - val errorMessage = - followState.state - .collectAsState() - .value - .errorOrNull() - errorMessage?.let { error -> - Spacer(Modifier.height(4.dp)) - Text( - error, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) + val errorMessage = + followState.state + .collectAsState() + .value + .errorOrNull() + errorMessage?.let { error -> + Spacer(Modifier.height(4.dp)) + Text( + error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } } } } } - } - // Profile card - item(key = "profile-card") { - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - ), - ) { - Column(modifier = Modifier.padding(16.dp)) { - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.Top, - ) { - UserAvatar( - userHex = pubKeyHex, - pictureUrl = picture, - size = 56.dp, - contentDescription = "Profile picture", - ) - - Column(modifier = Modifier.weight(1f)) { - Text( - displayName ?: (pubKeyHex.hexToByteArrayOrNull()?.toNpub()?.take(20) ?: pubKeyHex.take(20)), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, + // Profile card + item(key = "profile-card") { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top, + ) { + UserAvatar( + userHex = pubKeyHex, + pictureUrl = picture, + size = 56.dp, + contentDescription = "Profile picture", ) - Spacer(Modifier.height(4.dp)) - val npub = pubKeyHex.hexToByteArrayOrNull()?.toNpub() - var copied by remember { mutableStateOf(false) } - LaunchedEffect(copied) { - if (copied) { - delay(2000) - copied = false + Column(modifier = Modifier.weight(1f)) { + Text( + displayName ?: (pubKeyHex.hexToByteArrayOrNull()?.toNpub()?.take(20) ?: pubKeyHex.take(20)), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.height(4.dp)) + val npub = pubKeyHex.hexToByteArrayOrNull()?.toNpub() + var copied by remember { mutableStateOf(false) } + + LaunchedEffect(copied) { + if (copied) { + delay(2000) + copied = false + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + (npub?.take(32) ?: pubKeyHex.take(32)) + "...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (npub != null) { + IconButton( + onClick = { + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + clipboard.setContents(StringSelection(npub), null) + copied = true + }, + modifier = Modifier.size(20.dp), + ) { + Icon( + if (copied) MaterialSymbols.Check else MaterialSymbols.ContentCopy, + contentDescription = if (copied) "Copied" else "Copy npub", + modifier = Modifier.size(14.dp), + tint = + if (copied) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } } } + } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { + if (about != null) { + Spacer(Modifier.height(12.dp)) + Text( + about!!, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + Spacer(Modifier.height(12.dp)) + + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Column { Text( - (npub?.take(32) ?: pubKeyHex.take(32)) + "...", + "$followersCount", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + Text( + "Followers", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - if (npub != null) { - IconButton( - onClick = { - val clipboard = Toolkit.getDefaultToolkit().systemClipboard - clipboard.setContents(StringSelection(npub), null) - copied = true - }, - modifier = Modifier.size(20.dp), - ) { - Icon( - if (copied) MaterialSymbols.Check else MaterialSymbols.ContentCopy, - contentDescription = if (copied) "Copied" else "Copy npub", - modifier = Modifier.size(14.dp), - tint = - if (copied) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, + } + Column { + Text( + "$followingCount", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + Text( + "Following", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + + // Tabs + item(key = "tabs") { + PrimaryTabRow(selectedTabIndex = selectedTab) { + Tab(selected = selectedTab == 0, onClick = { selectedTab = 0 }) { + Text("Notes", modifier = Modifier.padding(12.dp)) + } + Tab(selected = selectedTab == 1, onClick = { selectedTab = 1 }) { + Text( + "Reads${if (articleEvents.isNotEmpty()) " (${articleEvents.size})" else ""}", + modifier = Modifier.padding(12.dp), + ) + } + Tab(selected = selectedTab == 2, onClick = { selectedTab = 2 }) { + Text("Gallery", modifier = Modifier.padding(12.dp)) + } + Tab(selected = selectedTab == 3, onClick = { selectedTab = 3 }) { + Text( + "Highlights${if (highlightEvents.isNotEmpty()) " (${highlightEvents.size})" else ""}", + modifier = Modifier.padding(12.dp), + ) + } + } + } + + // Tab content + when (selectedTab) { + 0 -> { + when (profileFeedState) { + is FeedState.Loading -> { + item(key = "loading") { + Box( + modifier = Modifier.fillMaxWidth().padding(32.dp), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + androidx.compose.material3.CircularProgressIndicator() + Spacer(Modifier.height(16.dp)) + Text( + "Loading posts...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } } - } - if (about != null) { - Spacer(Modifier.height(12.dp)) - Text( - about!!, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - } - - Spacer(Modifier.height(12.dp)) - - Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - Column { - Text( - "$followersCount", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - ) - Text( - "Followers", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Column { - Text( - "$followingCount", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - ) - Text( - "Following", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } - } - - // Tabs - item(key = "tabs") { - PrimaryTabRow(selectedTabIndex = selectedTab) { - Tab(selected = selectedTab == 0, onClick = { selectedTab = 0 }) { - Text("Notes", modifier = Modifier.padding(12.dp)) - } - Tab(selected = selectedTab == 1, onClick = { selectedTab = 1 }) { - Text( - "Reads${if (articleEvents.isNotEmpty()) " (${articleEvents.size})" else ""}", - modifier = Modifier.padding(12.dp), - ) - } - Tab(selected = selectedTab == 2, onClick = { selectedTab = 2 }) { - Text("Gallery", modifier = Modifier.padding(12.dp)) - } - Tab(selected = selectedTab == 3, onClick = { selectedTab = 3 }) { - Text( - "Highlights${if (highlightEvents.isNotEmpty()) " (${highlightEvents.size})" else ""}", - modifier = Modifier.padding(12.dp), - ) - } - } - } - - // Tab content - when (selectedTab) { - 0 -> { - when (profileFeedState) { - is FeedState.Loading -> { - item(key = "loading") { - Box( - modifier = Modifier.fillMaxWidth().padding(32.dp), - contentAlignment = Alignment.Center, - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - androidx.compose.material3.CircularProgressIndicator() - Spacer(Modifier.height(16.dp)) + is FeedState.Empty -> { + item(key = "empty") { + Box( + modifier = Modifier.fillMaxWidth().padding(32.dp), + contentAlignment = Alignment.Center, + ) { Text( - "Loading posts...", + "No posts yet", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } - } - is FeedState.Empty -> { - item(key = "empty") { + is FeedState.FeedError -> { + item(key = "error") { + Box( + modifier = Modifier.fillMaxWidth().padding(32.dp), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + "Failed to load posts", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error, + ) + Spacer(Modifier.height(8.dp)) + Text( + (profileFeedState as FeedState.FeedError).errorMessage, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(16.dp)) + OutlinedButton(onClick = { retryTrigger++ }) { + Text("Retry") + } + } + } + } + } + + is FeedState.Loaded -> { + // loadedNotes collected outside LazyColumn in profileLoadedNotes + items(profileLoadedNotes, key = { it.idHex }) { note -> + FeedNoteCard( + note = note, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + onReply = onCompose, + onZapFeedback = onZapFeedback, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + onImageClick = { urls, index -> + lightboxState = LightboxState(urls, index) + }, + onMediaClick = { urls, index, seekPos -> + com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + .playVideo(urls[index], seekPos) + com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + .toggleFullscreen() + }, + ) + } + } + } + } + + 1 -> { + if (articleEvents.isEmpty()) { + item(key = "no-articles") { Box( modifier = Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center, ) { Text( - "No posts yet", + "No long-form articles", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } + } else { + items( + articleEvents.sortedWith(compareByDescending { it.publishedAt() ?: it.createdAt }.thenBy { it.id }), + key = { "art-${it.id}" }, + ) { article -> + LongFormCard( + event = article, + localCache = localCache, + onAuthorClick = { onNavigateToProfile(article.pubKey) }, + onClick = { + val addressTag = "${LongTextNoteEvent.KIND}:${article.pubKey}:${article.dTag()}" + onNavigateToArticle(addressTag) + }, + ) + } } + } - is FeedState.FeedError -> { - item(key = "error") { + 2 -> { + item(key = "gallery") { + GalleryTab( + pictureEvents = pictureEvents, + onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) }, + modifier = Modifier.fillParentMaxHeight(), + ) + } + } + + 3 -> { + if (highlightEvents.isEmpty()) { + item(key = "no-highlights") { Box( modifier = Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center, ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - "Failed to load posts", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.error, - ) - Spacer(Modifier.height(8.dp)) - Text( - (profileFeedState as FeedState.FeedError).errorMessage, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(16.dp)) - OutlinedButton(onClick = { retryTrigger++ }) { - Text("Retry") - } - } + Text( + "No published highlights", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } - } - - is FeedState.Loaded -> { - // loadedNotes collected outside LazyColumn in profileLoadedNotes - items(profileLoadedNotes, key = { it.idHex }) { note -> - FeedNoteCard( - note = note, - relayManager = relayManager, + } else { + items( + highlightEvents.sortedWith(compareByDescending { it.createdAt }.thenBy { it.id }), + key = { "hl-${it.id}" }, + ) { highlight -> + PublishedHighlightCard( + highlight = highlight, localCache = localCache, - account = account, - nwcConnection = nwcConnection, - onReply = onCompose, - onZapFeedback = onZapFeedback, - onNavigateToProfile = onNavigateToProfile, - onNavigateToThread = onNavigateToThread, - onImageClick = { urls, index -> - lightboxState = LightboxState(urls, index) - }, - onMediaClick = { urls, index, seekPos -> - com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer - .playVideo(urls[index], seekPos) - com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer - .toggleFullscreen() - }, ) } } } } - - 1 -> { - if (articleEvents.isEmpty()) { - item(key = "no-articles") { - Box( - modifier = Modifier.fillMaxWidth().padding(32.dp), - contentAlignment = Alignment.Center, - ) { - Text( - "No long-form articles", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } else { - items( - articleEvents.sortedWith(compareByDescending { it.publishedAt() ?: it.createdAt }.thenBy { it.id }), - key = { "art-${it.id}" }, - ) { article -> - LongFormCard( - event = article, - localCache = localCache, - onAuthorClick = { onNavigateToProfile(article.pubKey) }, - onClick = { - val addressTag = "${LongTextNoteEvent.KIND}:${article.pubKey}:${article.dTag()}" - onNavigateToArticle(addressTag) - }, - ) - } - } - } - - 2 -> { - item(key = "gallery") { - GalleryTab( - pictureEvents = pictureEvents, - onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) }, - modifier = Modifier.fillParentMaxHeight(), - ) - } - } - - 3 -> { - if (highlightEvents.isEmpty()) { - item(key = "no-highlights") { - Box( - modifier = Modifier.fillMaxWidth().padding(32.dp), - contentAlignment = Alignment.Center, - ) { - Text( - "No published highlights", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } else { - items( - highlightEvents.sortedWith(compareByDescending { it.createdAt }.thenBy { it.id }), - key = { "hl-${it.id}" }, - ) { highlight -> - PublishedHighlightCard( - highlight = highlight, - localCache = localCache, - ) - } - } - } } } - } - // Floating header — appears on scroll up when profile header is out of view - AnimatedVisibility( - visible = showFloatingHeader, - enter = slideInVertically { -it }, - exit = slideOutVertically { -it }, - modifier = Modifier.align(Alignment.TopCenter).fillMaxWidth(), - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.95f)) - .padding(horizontal = 8.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, + // Floating header — appears on scroll up when profile header is out of view + AnimatedVisibility( + visible = showFloatingHeader, + enter = slideInVertically { -it }, + exit = slideOutVertically { -it }, + modifier = Modifier.align(Alignment.TopCenter).fillMaxWidth(), ) { - IconButton(onClick = onBack) { - Icon(MaterialSymbols.AutoMirrored.ArrowBack, "Back") + Row( + modifier = + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.95f)) + .padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (canGoBack) { + IconButton(onClick = onBack, modifier = Modifier.size(32.dp)) { + Icon( + MaterialSymbols.AutoMirrored.ArrowBack, + contentDescription = "Back", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + } + Spacer(Modifier.width(4.dp)) + } + UserAvatar( + userHex = pubKeyHex, + pictureUrl = picture, + size = 28.dp, + contentDescription = "Profile picture", + ) + Spacer(Modifier.width(8.dp)) + Text( + displayName ?: pubKeyHex.take(12) + "...", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + ) } - Spacer(Modifier.width(8.dp)) - UserAvatar( - userHex = pubKeyHex, - pictureUrl = picture, - size = 28.dp, - contentDescription = "Profile picture", - ) - Spacer(Modifier.width(8.dp)) - Text( - displayName ?: pubKeyHex.take(12) + "...", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - maxLines = 1, - ) } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index d622e3c74..d41b1de86 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -460,6 +460,7 @@ internal fun OverlayContent( nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, onBack = onBack, + canGoBack = true, onCompose = onShowComposeDialog, onNavigateToProfile = onNavigateToProfile, onNavigateToThread = onNavigateToThread, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt index b8bb5129b..f6fc2d797 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt @@ -26,9 +26,9 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn @@ -72,15 +72,21 @@ fun MyHighlightsScreen( val scope = rememberCoroutineScope() var deleteTarget by remember { mutableStateOf(null) } - Column(modifier = Modifier.fillMaxSize()) { - Text( - "Highlights", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - ) - - Spacer(Modifier.height(8.dp)) + com.vitorpamplona.amethyst.desktop.ui.ReadingColumn { + Row( + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "Highlights", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + } if (allHighlights.isEmpty()) { EmptyState( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt index 7cc2500c0..4f87e08cd 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt @@ -160,8 +160,9 @@ fun NoteCard( modifier = modifier.fillMaxWidth(), colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, + containerColor = MaterialTheme.colorScheme.surface, ), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), ) { Column(modifier = Modifier.padding(12.dp)) { // Header + text area — clickable to navigate to thread