fix(ui): show full post text, scrollable profile header with floating bar
- Remove maxLines=10 on post text so content is never truncated - Profile header/card/tabs now scroll with content instead of staying sticky — scrolls away naturally as you browse posts - Floating compact header (back + avatar + name) slides in when scrolling up and the full header is out of view Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+380
-306
@@ -20,6 +20,10 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.desktop.ui
|
package com.vitorpamplona.amethyst.desktop.ui
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
|
import androidx.compose.animation.slideOutVertically
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -33,6 +37,7 @@ import androidx.compose.foundation.layout.size
|
|||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
@@ -339,330 +344,362 @@ fun UserProfileScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
// Scroll state for detecting scroll direction
|
||||||
// Broadcast banner for profile updates
|
val listState = rememberLazyListState()
|
||||||
ProfileBroadcastBanner(
|
var showFloatingHeader by remember { mutableStateOf(false) }
|
||||||
status = broadcastStatus,
|
var previousFirstVisibleItemIndex by remember { mutableStateOf(0) }
|
||||||
onTap = {
|
var previousFirstVisibleItemScrollOffset by remember { mutableStateOf(0) }
|
||||||
// Clear banner on tap (could add retry logic for failed)
|
|
||||||
if (broadcastStatus is ProfileBroadcastStatus.Success ||
|
|
||||||
broadcastStatus is ProfileBroadcastStatus.Failed
|
|
||||||
) {
|
|
||||||
broadcastStatus = ProfileBroadcastStatus.Idle
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
// Header with back button
|
// Show floating header when scrolling up and header is scrolled out of view
|
||||||
Row(
|
LaunchedEffect(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) {
|
||||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
val currentIndex = listState.firstVisibleItemIndex
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
val currentOffset = listState.firstVisibleItemScrollOffset
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
val scrollingUp =
|
||||||
) {
|
currentIndex < previousFirstVisibleItemIndex ||
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
(currentIndex == previousFirstVisibleItemIndex && currentOffset < previousFirstVisibleItemScrollOffset)
|
||||||
IconButton(onClick = onBack) {
|
|
||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
|
|
||||||
}
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Text(
|
|
||||||
"Profile",
|
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Edit button for own profile
|
// Header items are indices 0-3, so if first visible >= 3, header is out of view
|
||||||
if (isOwnProfile && account.isReadOnly == false) {
|
showFloatingHeader = scrollingUp && currentIndex >= 3
|
||||||
OutlinedButton(
|
if (!scrollingUp && currentIndex < 3) showFloatingHeader = false
|
||||||
onClick = {
|
|
||||||
editingDisplayName = displayName ?: ""
|
|
||||||
showEditDialog = true
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Edit,
|
|
||||||
contentDescription = "Edit profile",
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Text("Edit Profile")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Follow/Unfollow button for other profiles
|
previousFirstVisibleItemIndex = currentIndex
|
||||||
if (account != null && !account.isReadOnly && pubKeyHex != account.pubKeyHex) {
|
previousFirstVisibleItemScrollOffset = currentOffset
|
||||||
Column(horizontalAlignment = Alignment.End) {
|
}
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
scope.launch {
|
|
||||||
val currentStatus = followState.currentStatusOrNull()
|
|
||||||
|
|
||||||
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) Icons.Default.PersonRemove else Icons.Default.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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
if (connectedRelays.isEmpty()) {
|
if (connectedRelays.isEmpty()) {
|
||||||
LoadingState("Connecting to relays...")
|
LoadingState("Connecting to relays...")
|
||||||
} else {
|
} else {
|
||||||
// Profile card
|
LazyColumn(
|
||||||
Card(
|
state = listState,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
colors =
|
modifier = Modifier.fillMaxSize(),
|
||||||
CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
),
|
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
// Broadcast banner
|
||||||
|
item(key = "broadcast") {
|
||||||
|
ProfileBroadcastBanner(
|
||||||
|
status = broadcastStatus,
|
||||||
|
onTap = {
|
||||||
|
if (broadcastStatus is ProfileBroadcastStatus.Success ||
|
||||||
|
broadcastStatus is ProfileBroadcastStatus.Failed
|
||||||
|
) {
|
||||||
|
broadcastStatus = ProfileBroadcastStatus.Idle
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header with back button
|
||||||
|
item(key = "header") {
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||||
verticalAlignment = Alignment.Top,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
// Profile picture with robohash fallback
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
UserAvatar(
|
IconButton(onClick = onBack) {
|
||||||
userHex = pubKeyHex,
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
|
||||||
pictureUrl = picture,
|
}
|
||||||
size = 56.dp,
|
Spacer(Modifier.width(8.dp))
|
||||||
contentDescription = "Profile picture",
|
|
||||||
)
|
|
||||||
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
Text(
|
||||||
displayName ?: (pubKeyHex.hexToByteArrayOrNull()?.toNpub()?.take(20) ?: pubKeyHex.take(20)),
|
"Profile",
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(4.dp))
|
}
|
||||||
val npub = pubKeyHex.hexToByteArrayOrNull()?.toNpub()
|
|
||||||
var copied by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
// Reset copied state after delay
|
// Edit button for own profile
|
||||||
LaunchedEffect(copied) {
|
if (isOwnProfile && account.isReadOnly == false) {
|
||||||
if (copied) {
|
OutlinedButton(
|
||||||
delay(2000)
|
onClick = {
|
||||||
copied = false
|
editingDisplayName = displayName ?: ""
|
||||||
|
showEditDialog = true
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Edit,
|
||||||
|
contentDescription = "Edit profile",
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Edit Profile")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Follow/Unfollow button for other profiles
|
||||||
|
if (account != null && !account.isReadOnly && pubKeyHex != account.pubKeyHex) {
|
||||||
|
Column(horizontalAlignment = Alignment.End) {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
val currentStatus = followState.currentStatusOrNull()
|
||||||
|
|
||||||
|
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) Icons.Default.PersonRemove else Icons.Default.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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
)
|
||||||
|
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) Icons.Default.Check else Icons.Default.ContentCopy,
|
||||||
|
contentDescription = if (copied) "Copied" else "Copy npub",
|
||||||
|
modifier = Modifier.size(14.dp),
|
||||||
|
tint =
|
||||||
|
if (copied) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Row(
|
if (about != null) {
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
Spacer(Modifier.height(12.dp))
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
(npub?.take(32) ?: pubKeyHex.take(32)) + "...",
|
about!!,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
if (npub != null) {
|
}
|
||||||
IconButton(
|
|
||||||
onClick = {
|
Spacer(Modifier.height(12.dp))
|
||||||
val clipboard = Toolkit.getDefaultToolkit().systemClipboard
|
|
||||||
clipboard.setContents(StringSelection(npub), null)
|
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
copied = true
|
Column {
|
||||||
},
|
Text(
|
||||||
modifier = Modifier.size(20.dp),
|
"$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("Gallery", modifier = Modifier.padding(12.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab content
|
||||||
|
when (selectedTab) {
|
||||||
|
0 -> {
|
||||||
|
when {
|
||||||
|
postsError != null -> {
|
||||||
|
item(key = "error") {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(32.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
if (copied) Icons.Default.Check else Icons.Default.ContentCopy,
|
Text(
|
||||||
contentDescription = if (copied) "Copied" else "Copy npub",
|
"Failed to load posts",
|
||||||
modifier = Modifier.size(14.dp),
|
style = MaterialTheme.typography.titleMedium,
|
||||||
tint =
|
color = MaterialTheme.colorScheme.error,
|
||||||
if (copied) {
|
)
|
||||||
MaterialTheme.colorScheme.primary
|
Spacer(Modifier.height(8.dp))
|
||||||
} else {
|
Text(
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
postsError!!,
|
||||||
},
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
OutlinedButton(onClick = { retryTrigger++ }) {
|
||||||
|
Text("Retry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
postsLoading -> {
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
events.isEmpty() -> {
|
||||||
|
item(key = "empty") {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(32.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"No posts yet",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (about != null) {
|
else -> {
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
|
|
||||||
// Notes / Gallery 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("Gallery", modifier = Modifier.padding(12.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
|
|
||||||
when (selectedTab) {
|
|
||||||
0 -> {
|
|
||||||
when {
|
|
||||||
postsError != null -> {
|
|
||||||
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(
|
|
||||||
postsError!!,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
OutlinedButton(onClick = { retryTrigger++ }) {
|
|
||||||
Text("Retry")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
postsLoading -> {
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
events.isEmpty() -> {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(32.dp),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"No posts yet",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
LazyColumn(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
items(events.distinctBy { it.id }, key = { it.id }) { event ->
|
items(events.distinctBy { it.id }, key = { it.id }) { event ->
|
||||||
FeedNoteCard(
|
FeedNoteCard(
|
||||||
event = event,
|
event = event,
|
||||||
@@ -684,16 +721,53 @@ fun UserProfileScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
1 -> {
|
1 -> {
|
||||||
GalleryTab(
|
item(key = "gallery") {
|
||||||
pictureEvents = pictureEvents,
|
GalleryTab(
|
||||||
onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) },
|
pictureEvents = pictureEvents,
|
||||||
)
|
onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lightbox overlay
|
// Lightbox overlay
|
||||||
|
|||||||
+1
-1
@@ -285,7 +285,7 @@ fun RichTextContent(
|
|||||||
content: String,
|
content: String,
|
||||||
urls: Urls,
|
urls: Urls,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
maxLines: Int = 10,
|
maxLines: Int = Int.MAX_VALUE,
|
||||||
) {
|
) {
|
||||||
if (urls.withScheme.isEmpty()) {
|
if (urls.withScheme.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
Reference in New Issue
Block a user