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:
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -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,
|
||||
) {
|
||||
|
||||
+13
-6
@@ -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,
|
||||
|
||||
+1
-2
@@ -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(
|
||||
|
||||
+214
-203
@@ -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),
|
||||
|
||||
+431
-415
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -460,6 +460,7 @@ internal fun OverlayContent(
|
||||
nwcConnection = nwcConnection,
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
onBack = onBack,
|
||||
canGoBack = true,
|
||||
onCompose = onShowComposeDialog,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
onNavigateToThread = onNavigateToThread,
|
||||
|
||||
+16
-10
@@ -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(
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user