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.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")
}
}
}
@@ -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,
) {
@@ -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<DraftEntry?>(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),
)
}
}
@@ -489,7 +489,7 @@ fun FeedScreen(
}
Box(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize()) {
ReadingColumn {
// Header with compose button
FeedHeader(
feedMode = feedMode,
@@ -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,
@@ -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.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(
@@ -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<com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl>().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<com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl>().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() },
)
}
}
}
@@ -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),
@@ -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<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 -> {
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<HighlightEvent> { 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<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
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,
)
}
}
}
@@ -460,6 +460,7 @@ internal fun OverlayContent(
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
onBack = onBack,
canGoBack = true,
onCompose = onShowComposeDialog,
onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
@@ -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<HighlightData?>(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(
@@ -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