feat(desktop): ReadingColumn width cap, conditional Profile back, card styling pass

Introduces ReadingColumn, a top-level scaffold that caps feed/list
screens at 720 dp and centers them — matches the Twitter/Mastodon
desktop pattern so cards don't stretch disproportionately on 4K
displays. Applied to: Home, Reads, Notifications, Bookmarks, Drafts,
Highlights, Search, Thread, Profile, Settings. Messages stays
full-width (its own two-pane sizing). Chess, Relay Dashboard, Article
Reader/Editor keep their current full-width layouts (tools/reading
logic dictates width independently).

Header consistency:
- Bookmarks / Drafts / Highlights / Search now have a minimum header
  row height of 48dp so screens without action buttons sit at the
  same visual weight as screens with IconButtons.
- Drafts' "New Draft" button converted from text Button to IconButton
  for consistency with the other screens' icon-only actions.
- Settings title switched from headlineMedium to titleMedium and
  wrapped in the standard h=12/v=8 header row.

Profile back button:
- UserProfileScreen now takes canGoBack: Boolean = false. The back
  arrow only renders when stacked onto a nav stack (clicking a user
  in the feed / notifications). Top-level "My Profile" accessed via
  the nav rail has no back arrow — nothing above it to pop.
- Applied to both the in-header back button and the floating
  scroll-aware header that appears when scrolling through posts.

Home cards:
- NoteCard switched from surfaceVariant fill (gray) to surface (white)
  + 1dp elevation, matching LongFormCard on Reads. The subtle shadow
  gives the feed the same lift as the articles screen.

https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
This commit is contained in:
Claude
2026-04-24 15:11:48 +00:00
parent 08f9c8bfd4
commit d212b88103
13 changed files with 946 additions and 816 deletions
@@ -29,7 +29,9 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@@ -1319,213 +1321,232 @@ fun RelaySettingsScreen(
accountManager.loadNwcConnection() accountManager.loadNwcConnection()
} }
Column( Box(
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.TopCenter,
) { ) {
Text( Column(
"Settings", modifier =
style = MaterialTheme.typography.headlineMedium, Modifier
color = MaterialTheme.colorScheme.onBackground, .fillMaxSize()
) .widthIn(max = 720.dp)
.verticalScroll(rememberScrollState())
Spacer(Modifier.height(24.dp)) .padding(horizontal = 12.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( Row(
modifier = Modifier.fillMaxWidth(), modifier =
horizontalArrangement = Arrangement.SpaceBetween, Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Column { Text(
Text( "Settings",
"Wallet Connected", style = MaterialTheme.typography.titleMedium,
style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onBackground,
color = MaterialTheme.colorScheme.primary, )
) }
Text(
"Relay: ${nwcConnection!!.relayUri.url}", Spacer(Modifier.height(16.dp))
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, // Wallet Connect Section
) Text(
} "Wallet Connect (NWC)",
OutlinedButton( style = MaterialTheme.typography.titleLarge,
onClick = { accountManager.clearNwcConnection() }, color = MaterialTheme.colorScheme.onBackground,
colors = )
ButtonDefaults.outlinedButtonColors( Spacer(Modifier.height(8.dp))
contentColor = MaterialTheme.colorScheme.error,
), 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( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
OutlinedTextField( OutlinedTextField(
value = nwcInput, value = newRelayUrl,
onValueChange = { onValueChange = { newRelayUrl = it },
nwcInput = it label = { Text("Add relay") },
nwcError = null placeholder = { Text("wss://relay.example.com") },
},
label = { Text("NWC Connection String") },
placeholder = { Text("nostr+walletconnect://...") },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
singleLine = true, singleLine = true,
isError = nwcError != null,
supportingText = nwcError?.let { { Text(it, color = MaterialTheme.colorScheme.error) } },
) )
Button( Button(
onClick = { onClick = {
val result = accountManager.setNwcConnection(nwcInput) if (newRelayUrl.isNotBlank()) {
result.fold( relayManager.addRelay(newRelayUrl)
onSuccess = { nwcInput = "" }, newRelayUrl = ""
onFailure = { nwcError = it.message ?: "Invalid connection string" }, }
)
}, },
enabled = nwcInput.isNotBlank(), enabled = newRelayUrl.isNotBlank(),
) { ) {
Text("Connect") Text("Add")
} }
} }
}
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(16.dp))
HorizontalDivider()
Spacer(Modifier.height(24.dp))
// Media Server Settings LazyColumn(
MediaServerSettings( verticalArrangement = Arrangement.spacedBy(8.dp),
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") },
modifier = Modifier.weight(1f), 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( Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
verticalArrangement = Arrangement.spacedBy(8.dp), OutlinedButton(onClick = { relayManager.addDefaultRelays() }) {
modifier = Modifier.weight(1f), Text("Reset to Defaults")
) { }
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))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { val logoutScope = rememberCoroutineScope()
OutlinedButton(onClick = { relayManager.addDefaultRelays() }) { OutlinedButton(
Text("Reset to Defaults") 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")
}
} }
} }
@@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
@@ -247,12 +248,13 @@ fun BookmarksScreen(
val currentEvents = if (selectedTab == BookmarkTab.PUBLIC) publicEvents else privateEvents val currentEvents = if (selectedTab == BookmarkTab.PUBLIC) publicEvents else privateEvents
val currentBookmarkIds = if (selectedTab == BookmarkTab.PUBLIC) publicBookmarkIds else privateBookmarkIds val currentBookmarkIds = if (selectedTab == BookmarkTab.PUBLIC) publicBookmarkIds else privateBookmarkIds
Column(modifier = Modifier.fillMaxSize()) { ReadingColumn {
// Header with tabs // Header with tabs
Row( Row(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = 48.dp)
.padding(horizontal = 12.dp, vertical = 8.dp), .padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
@@ -26,14 +26,14 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -67,11 +67,12 @@ fun DraftsScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var deleteTarget by remember { mutableStateOf<DraftEntry?>(null) } var deleteTarget by remember { mutableStateOf<DraftEntry?>(null) }
Column(modifier = Modifier.fillMaxSize()) { ReadingColumn {
Row( Row(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = 48.dp)
.padding(horizontal = 12.dp, vertical = 8.dp), .padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -81,9 +82,15 @@ fun DraftsScreen(
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground, color = MaterialTheme.colorScheme.onBackground,
) )
Button(onClick = { onOpenEditor(null) }) { // Convert "New Draft" button to an icon for consistency with other
Icon(MaterialSymbols.Add, contentDescription = null) // screens' tabs-first + icon-actions header pattern.
Text("New Draft", modifier = Modifier.padding(start = 4.dp)) IconButton(onClick = { onOpenEditor(null) }, modifier = Modifier.size(32.dp)) {
Icon(
MaterialSymbols.Add,
contentDescription = "New Draft",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
} }
} }
@@ -489,7 +489,7 @@ fun FeedScreen(
} }
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize()) { ReadingColumn {
// Header with compose button // Header with compose button
FeedHeader( FeedHeader(
feedMode = feedMode, feedMode = feedMode,
@@ -25,7 +25,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -238,7 +237,7 @@ fun NotificationsScreen(
} }
} }
Column(modifier = Modifier.fillMaxSize()) { ReadingColumn {
FeedHeader( FeedHeader(
title = "Notifications", title = "Notifications",
connectedRelayCount = connectedRelays.size, connectedRelayCount = connectedRelays.size,
@@ -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,
)
}
}
@@ -26,7 +26,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -288,7 +287,7 @@ fun ReadsScreen(
} }
} }
Column(modifier = Modifier.fillMaxSize()) { ReadingColumn {
// Header — Messages-style: tabs left, refresh right. The selected tab // Header — Messages-style: tabs left, refresh right. The selected tab
// (Following / Global) acts as the screen title, so no separate label. // (Following / Global) acts as the screen title, so no separate label.
Row( Row(
@@ -34,9 +34,11 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -289,227 +291,236 @@ fun SearchScreen(
focusRequester.requestFocus() focusRequester.requestFocus()
} }
Column( androidx.compose.foundation.layout.Box(
modifier = modifier =
modifier androidx.compose.ui.Modifier
.fillMaxSize() .fillMaxSize(),
.onPreviewKeyEvent { event -> contentAlignment = androidx.compose.ui.Alignment.TopCenter,
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
}
}
},
) { ) {
// Progress bar at very top Column(
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 =
Modifier modifier
.fillMaxWidth() .fillMaxSize()
.padding(horizontal = 12.dp, vertical = 8.dp), .widthIn(max = DefaultReadingWidth)
horizontalArrangement = Arrangement.SpaceBetween, .onPreviewKeyEvent { event ->
verticalAlignment = Alignment.CenterVertically, if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
) { when (event.key) {
Text( Key.Escape -> {
"Search", if (panelExpanded) {
style = MaterialTheme.typography.titleMedium, state.togglePanel()
color = MaterialTheme.colorScheme.onBackground, } else if (displayText.isNotEmpty()) {
) state.clearSearch()
Text( }
"${localCache.userCount()} users cached", true
style = MaterialTheme.typography.bodySmall, }
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(16.dp)) else -> {
false
// Search bar with advanced toggle }
Row( }
modifier = Modifier.fillMaxWidth(), },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
OutlinedTextField( // Progress bar at very top
value = textFieldValue, AnimatedVisibility(
onValueChange = { visible = isSearching,
textFieldValue = it enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(),
state.updateFromText(it.text) exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(),
}, ) {
modifier = Modifier.weight(1f).focusRequester(focusRequester), LinearProgressIndicator(
placeholder = { Text("Search notes, people, tags... or use operators") }, modifier = Modifier.fillMaxWidth(),
leadingIcon = { 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( Icon(
MaterialSymbols.Search, MaterialSymbols.Tune,
contentDescription = "Search", contentDescription = "Advanced Search",
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint =
if (panelExpanded) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
) )
}, }
trailingIcon = { }
if (displayText.isNotEmpty()) {
IconButton(onClick = { state.clearSearch() }) { // Search relay picker dialog
Icon( if (showRelayPicker && account != null) {
MaterialSymbols.Clear, val pickerRelays =
contentDescription = "Clear", remember {
tint = MaterialTheme.colorScheme.onSurfaceVariant, mutableStateListOf<com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl>().also {
) it.addAll(searchRelays)
} }
} }
}, AlertDialog(
singleLine = true, onDismissRequest = { showRelayPicker = false },
shape = RoundedCornerShape(12.dp), title = { Text("Search Relays") },
) text = {
if (account != null && !account.isReadOnly) { SearchRelayEditor(
IconButton(onClick = { showRelayPicker = true }) { localRelays = pickerRelays,
Icon( signer = account.signer,
MaterialSymbols.Dns, onPublish = { event ->
contentDescription = "Search Relays", relayManager.broadcastToAll(event)
tint = MaterialTheme.colorScheme.onSurfaceVariant, accountRelays?.consumePublishedEvent(event)
) accountRelays?.setSearchRelays(pickerRelays.toSet())
} },
} )
IconButton(onClick = { state.togglePanel() }) { },
Icon( confirmButton = {
MaterialSymbols.Tune, TextButton(onClick = { showRelayPicker = false }) {
contentDescription = "Advanced Search", Text("Close")
tint = }
if (panelExpanded) { },
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
) )
} }
}
// Search relay picker dialog // Expandable advanced panel
if (showRelayPicker && account != null) { AnimatedVisibility(
val pickerRelays = visible = panelExpanded,
remember { enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(),
mutableStateListOf<com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl>().also { exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(),
it.addAll(searchRelays) ) {
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( } else if (hasAnyResults) {
onDismissRequest = { showRelayPicker = false }, SearchResultsList(
title = { Text("Search Relays") }, state = state,
text = { onNavigateToProfile = onNavigateToProfile,
SearchRelayEditor( onNavigateToThread = onNavigateToThread,
localRelays = pickerRelays, localCache = localCache,
signer = account.signer, )
onPublish = { event -> } else if (!debouncedQuery.isEmpty && !isSearching) {
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)) {
Text( Text(
"Direct lookup", "No results found. Try broader terms or fewer filters.",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, 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() },
)
} }
} }
} }
@@ -198,7 +198,7 @@ fun ThreadScreen(
val replyNotes = threadNotes.filter { it.idHex != noteId } val replyNotes = threadNotes.filter { it.idHex != noteId }
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize()) { ReadingColumn {
// Header — Messages-style: compact row with back + titleMedium // Header — Messages-style: compact row with back + titleMedium
Row( Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
@@ -36,6 +36,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
@@ -115,6 +116,7 @@ fun UserProfileScreen(
nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null, nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
onBack: () -> Unit, onBack: () -> Unit,
canGoBack: Boolean = false,
onCompose: () -> Unit = {}, onCompose: () -> Unit = {},
onNavigateToProfile: (String) -> Unit = {}, onNavigateToProfile: (String) -> Unit = {},
onNavigateToThread: (String) -> Unit = {}, onNavigateToThread: (String) -> Unit = {},
@@ -436,489 +438,503 @@ fun UserProfileScreen(
previousFirstVisibleItemScrollOffset = currentOffset previousFirstVisibleItemScrollOffset = currentOffset
} }
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
if (connectedRelays.isEmpty()) { Box(modifier = Modifier.fillMaxSize().widthIn(max = DefaultReadingWidth)) {
LoadingState("Connecting to relays...") if (connectedRelays.isEmpty()) {
} else { LoadingState("Connecting to relays...")
LazyColumn( } else {
state = listState, LazyColumn(
contentPadding = PaddingValues(horizontal = 12.dp), state = listState,
verticalArrangement = Arrangement.spacedBy(8.dp), contentPadding = PaddingValues(horizontal = 12.dp),
modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp),
) { modifier = Modifier.fillMaxSize(),
// Broadcast banner ) {
item(key = "broadcast") { // Broadcast banner
ProfileBroadcastBanner( item(key = "broadcast") {
status = broadcastStatus, ProfileBroadcastBanner(
onTap = { status = broadcastStatus,
if (broadcastStatus is ProfileBroadcastStatus.Success || onTap = {
broadcastStatus is ProfileBroadcastStatus.Failed if (broadcastStatus is ProfileBroadcastStatus.Success ||
) { broadcastStatus is ProfileBroadcastStatus.Failed
broadcastStatus = ProfileBroadcastStatus.Idle ) {
} broadcastStatus = ProfileBroadcastStatus.Idle
}, }
) },
} )
}
// Header — Messages-style: compact row, titleMedium title // Header — Messages-style: compact row, titleMedium title
item(key = "header") { item(key = "header") {
Row( Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onBack, modifier = Modifier.size(32.dp)) { // Back is shown only when stacked onto a nav stack (e.g.
Icon( // clicked a user in the feed). Top-level "My Profile"
MaterialSymbols.AutoMirrored.ArrowBack, // from the nav rail sets canGoBack = false so no arrow.
contentDescription = "Back", if (canGoBack) {
tint = MaterialTheme.colorScheme.primary, IconButton(onClick = onBack, modifier = Modifier.size(32.dp)) {
modifier = Modifier.size(20.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 // Edit button for own profile
if (isOwnProfile && account.isReadOnly == false) { if (isOwnProfile && account.isReadOnly == false) {
OutlinedButton( 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(
onClick = { onClick = {
scope.launch { editingDisplayName = displayName ?: ""
val currentStatus = followState.currentStatusOrNull() showEditDialog = true
},
) {
Icon(
MaterialSymbols.Edit,
contentDescription = "Edit profile",
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(8.dp))
Text("Edit Profile")
}
}
followState.setFollowLoading() // Follow/Unfollow button for other profiles
try { if (account != null && !account.isReadOnly && pubKeyHex != account.pubKeyHex) {
val updatedEvent = Column(horizontalAlignment = Alignment.End) {
if (currentStatus?.isFollowing == true) { Button(
unfollowUser(pubKeyHex, account, relayManager, myContactList) onClick = {
} else { scope.launch {
followUser(pubKeyHex, account, relayManager, myContactList) val currentStatus = followState.currentStatusOrNull()
}
// Update both stored contact list and followState followState.setFollowLoading()
myContactList = updatedEvent try {
followState.setFollowSuccess(updatedEvent, pubKeyHex) val updatedEvent =
} catch (e: Exception) { if (currentStatus?.isFollowing == true) {
e.printStackTrace() unfollowUser(pubKeyHex, account, relayManager, myContactList)
followState.setFollowError(e.message ?: "Failed to update follow status", e) } 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 = val errorMessage =
followState.state followState.state
.collectAsState() .collectAsState()
.value .value
.errorOrNull() .errorOrNull()
errorMessage?.let { error -> errorMessage?.let { error ->
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Text( Text(
error, error,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
) )
}
} }
} }
} }
} }
}
// Profile card // Profile card
item(key = "profile-card") { item(key = "profile-card") {
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = colors =
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant, containerColor = MaterialTheme.colorScheme.surfaceVariant,
), ),
) { ) {
Column(modifier = Modifier.padding(16.dp)) { Column(modifier = Modifier.padding(16.dp)) {
Row( Row(
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top, verticalAlignment = Alignment.Top,
) { ) {
UserAvatar( UserAvatar(
userHex = pubKeyHex, userHex = pubKeyHex,
pictureUrl = picture, pictureUrl = picture,
size = 56.dp, size = 56.dp,
contentDescription = "Profile picture", 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,
) )
Spacer(Modifier.height(4.dp))
val npub = pubKeyHex.hexToByteArrayOrNull()?.toNpub()
var copied by remember { mutableStateOf(false) }
LaunchedEffect(copied) { Column(modifier = Modifier.weight(1f)) {
if (copied) { Text(
delay(2000) displayName ?: (pubKeyHex.hexToByteArrayOrNull()?.toNpub()?.take(20) ?: pubKeyHex.take(20)),
copied = false 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( if (about != null) {
verticalAlignment = Alignment.CenterVertically, Spacer(Modifier.height(12.dp))
horizontalArrangement = Arrangement.spacedBy(4.dp), Text(
) { about!!,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
Spacer(Modifier.height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Column {
Text( Text(
(npub?.take(32) ?: pubKeyHex.take(32)) + "...", "$followersCount",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
)
Text(
"Followers",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
if (npub != null) { }
IconButton( Column {
onClick = { Text(
val clipboard = Toolkit.getDefaultToolkit().systemClipboard "$followingCount",
clipboard.setContents(StringSelection(npub), null) style = MaterialTheme.typography.titleSmall,
copied = true fontWeight = FontWeight.Bold,
}, )
modifier = Modifier.size(20.dp), Text(
) { "Following",
Icon( style = MaterialTheme.typography.bodySmall,
if (copied) MaterialSymbols.Check else MaterialSymbols.ContentCopy, color = MaterialTheme.colorScheme.onSurfaceVariant,
contentDescription = if (copied) "Copied" else "Copy npub", )
modifier = Modifier.size(14.dp), }
tint = }
if (copied) { }
MaterialTheme.colorScheme.primary }
} else { }
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) { is FeedState.Empty -> {
Spacer(Modifier.height(12.dp)) item(key = "empty") {
Text( Box(
about!!, modifier = Modifier.fillMaxWidth().padding(32.dp),
style = MaterialTheme.typography.bodyMedium, contentAlignment = Alignment.Center,
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))
Text( Text(
"Loading posts...", "No posts yet",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
} }
} }
}
is FeedState.Empty -> { is FeedState.FeedError -> {
item(key = "empty") { 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( Box(
modifier = Modifier.fillMaxWidth().padding(32.dp), modifier = Modifier.fillMaxWidth().padding(32.dp),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Text( Text(
"No posts yet", "No long-form articles",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
} }
} else {
items(
articleEvents.sortedWith(compareByDescending<LongTextNoteEvent> { 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 -> { 2 -> {
item(key = "error") { 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( Box(
modifier = Modifier.fillMaxWidth().padding(32.dp), modifier = Modifier.fillMaxWidth().padding(32.dp),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Text(
Text( "No published highlights",
"Failed to load posts", style = MaterialTheme.typography.bodyMedium,
style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
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")
}
}
} }
} }
} } else {
items(
is FeedState.Loaded -> { highlightEvents.sortedWith(compareByDescending<HighlightEvent> { it.createdAt }.thenBy { it.id }),
// loadedNotes collected outside LazyColumn in profileLoadedNotes key = { "hl-${it.id}" },
items(profileLoadedNotes, key = { it.idHex }) { note -> ) { highlight ->
FeedNoteCard( PublishedHighlightCard(
note = note, highlight = highlight,
relayManager = relayManager,
localCache = localCache, 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<LongTextNoteEvent> { 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<HighlightEvent> { 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 // Floating header — appears on scroll up when profile header is out of view
AnimatedVisibility( AnimatedVisibility(
visible = showFloatingHeader, visible = showFloatingHeader,
enter = slideInVertically { -it }, enter = slideInVertically { -it },
exit = slideOutVertically { -it }, exit = slideOutVertically { -it },
modifier = Modifier.align(Alignment.TopCenter).fillMaxWidth(), 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,
) { ) {
IconButton(onClick = onBack) { Row(
Icon(MaterialSymbols.AutoMirrored.ArrowBack, "Back") 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,
)
} }
} }
} }
@@ -460,6 +460,7 @@ internal fun OverlayContent(
nwcConnection = nwcConnection, nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator, subscriptionsCoordinator = subscriptionsCoordinator,
onBack = onBack, onBack = onBack,
canGoBack = true,
onCompose = onShowComposeDialog, onCompose = onShowComposeDialog,
onNavigateToProfile = onNavigateToProfile, onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread, onNavigateToThread = onNavigateToThread,
@@ -26,9 +26,9 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@@ -72,15 +72,21 @@ fun MyHighlightsScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var deleteTarget by remember { mutableStateOf<HighlightData?>(null) } var deleteTarget by remember { mutableStateOf<HighlightData?>(null) }
Column(modifier = Modifier.fillMaxSize()) { com.vitorpamplona.amethyst.desktop.ui.ReadingColumn {
Text( Row(
"Highlights", modifier =
style = MaterialTheme.typography.titleMedium, Modifier
color = MaterialTheme.colorScheme.onBackground, .fillMaxWidth()
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), .heightIn(min = 48.dp)
) .padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
Spacer(Modifier.height(8.dp)) ) {
Text(
"Highlights",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
)
}
if (allHighlights.isEmpty()) { if (allHighlights.isEmpty()) {
EmptyState( EmptyState(
@@ -160,8 +160,9 @@ fun NoteCard(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
colors = colors =
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant, containerColor = MaterialTheme.colorScheme.surface,
), ),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
) { ) {
Column(modifier = Modifier.padding(12.dp)) { Column(modifier = Modifier.padding(12.dp)) {
// Header + text area — clickable to navigate to thread // Header + text area — clickable to navigate to thread