feat(media): wire profile gallery tab and lightbox
- Add Notes/Gallery PrimaryTabRow to UserProfileScreen - Subscribe to PictureEvent (kind 20) for gallery grid - Wire GalleryTab composable with image click callbacks - Wire LightboxOverlay for full-screen image viewing - Pass onImageClick to FeedNoteCard in notes tab Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+141
-78
@@ -49,12 +49,15 @@ import androidx.compose.material3.IconButton
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.PrimaryTabRow
|
||||||
|
import androidx.compose.material3.Tab
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
@@ -79,12 +82,16 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig
|
|||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription
|
import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createMetadataSubscription
|
import com.vitorpamplona.amethyst.desktop.subscriptions.createMetadataSubscription
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createUserPostsSubscription
|
import com.vitorpamplona.amethyst.desktop.subscriptions.createUserPostsSubscription
|
||||||
|
import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
|
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
|
||||||
|
import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay
|
||||||
|
import com.vitorpamplona.amethyst.desktop.ui.profile.GalleryTab
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
|
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
|
||||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
||||||
|
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -141,6 +148,11 @@ fun UserProfileScreen(
|
|||||||
var postsError by remember { mutableStateOf<String?>(null) }
|
var postsError by remember { mutableStateOf<String?>(null) }
|
||||||
var retryTrigger by remember { mutableStateOf(0) }
|
var retryTrigger by remember { mutableStateOf(0) }
|
||||||
|
|
||||||
|
// Tab and gallery state
|
||||||
|
var selectedTab by remember { mutableStateOf(0) }
|
||||||
|
var lightboxState by remember { mutableStateOf<Pair<List<String>, Int>?>(null) }
|
||||||
|
val pictureEvents = remember { mutableStateListOf<PictureEvent>() }
|
||||||
|
|
||||||
// Follow state
|
// Follow state
|
||||||
val followState =
|
val followState =
|
||||||
remember(account) {
|
remember(account) {
|
||||||
@@ -300,6 +312,33 @@ fun UserProfileScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Subscribe to picture events (kind 20) for gallery tab
|
||||||
|
rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) {
|
||||||
|
if (connectedRelays.isNotEmpty()) {
|
||||||
|
pictureEvents.clear()
|
||||||
|
SubscriptionConfig(
|
||||||
|
subId = generateSubId("pics-${pubKeyHex.take(8)}"),
|
||||||
|
filters =
|
||||||
|
listOf(
|
||||||
|
FilterBuilders.byAuthors(
|
||||||
|
authors = listOf(pubKeyHex),
|
||||||
|
kinds = listOf(PictureEvent.KIND),
|
||||||
|
limit = 100,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
relays = connectedRelays,
|
||||||
|
onEvent = { event, _, _, _ ->
|
||||||
|
if (event is PictureEvent && pictureEvents.none { it.id == event.id }) {
|
||||||
|
pictureEvents.add(event)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onEose = { _, _ -> },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
// Broadcast banner for profile updates
|
// Broadcast banner for profile updates
|
||||||
ProfileBroadcastBanner(
|
ProfileBroadcastBanner(
|
||||||
@@ -550,95 +589,119 @@ fun UserProfileScreen(
|
|||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
// User's posts
|
// Notes / Gallery tabs
|
||||||
Text(
|
PrimaryTabRow(selectedTabIndex = selectedTab) {
|
||||||
"Posts",
|
Tab(selected = selectedTab == 0, onClick = { selectedTab = 0 }) {
|
||||||
style = MaterialTheme.typography.titleMedium,
|
Text("Notes", modifier = Modifier.padding(12.dp))
|
||||||
modifier = Modifier.padding(bottom = 8.dp),
|
}
|
||||||
)
|
Tab(selected = selectedTab == 1, onClick = { selectedTab = 1 }) {
|
||||||
|
Text("Gallery", modifier = Modifier.padding(12.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
when {
|
Spacer(Modifier.height(8.dp))
|
||||||
postsError != null -> {
|
|
||||||
// Error state with retry
|
when (selectedTab) {
|
||||||
Box(
|
0 -> {
|
||||||
modifier = Modifier.fillMaxWidth().padding(32.dp),
|
when {
|
||||||
contentAlignment = Alignment.Center,
|
postsError != null -> {
|
||||||
) {
|
Box(
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
modifier = Modifier.fillMaxWidth().padding(32.dp),
|
||||||
Text(
|
contentAlignment = Alignment.Center,
|
||||||
"Failed to load posts",
|
) {
|
||||||
style = MaterialTheme.typography.titleMedium,
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
color = MaterialTheme.colorScheme.error,
|
Text(
|
||||||
)
|
"Failed to load posts",
|
||||||
Spacer(Modifier.height(8.dp))
|
style = MaterialTheme.typography.titleMedium,
|
||||||
Text(
|
color = MaterialTheme.colorScheme.error,
|
||||||
postsError!!,
|
)
|
||||||
style = MaterialTheme.typography.bodySmall,
|
Spacer(Modifier.height(8.dp))
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
Text(
|
||||||
)
|
postsError!!,
|
||||||
Spacer(Modifier.height(16.dp))
|
style = MaterialTheme.typography.bodySmall,
|
||||||
OutlinedButton(onClick = { retryTrigger++ }) {
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
Text("Retry")
|
)
|
||||||
|
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 ->
|
||||||
|
FeedNoteCard(
|
||||||
|
event = event,
|
||||||
|
relayManager = relayManager,
|
||||||
|
localCache = localCache,
|
||||||
|
account = account,
|
||||||
|
nwcConnection = nwcConnection,
|
||||||
|
onReply = onCompose,
|
||||||
|
onZapFeedback = onZapFeedback,
|
||||||
|
onNavigateToProfile = onNavigateToProfile,
|
||||||
|
onImageClick = { urls, index ->
|
||||||
|
lightboxState = urls to index
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
postsLoading -> {
|
1 -> {
|
||||||
// Loading state
|
GalleryTab(
|
||||||
Box(
|
pictureEvents = pictureEvents,
|
||||||
modifier = Modifier.fillMaxWidth().padding(32.dp),
|
onImageClick = { urls, index -> lightboxState = urls to index },
|
||||||
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() -> {
|
|
||||||
// Empty state (loaded but no posts)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(32.dp),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"No posts yet",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
// Posts loaded successfully
|
|
||||||
LazyColumn(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
items(events.distinctBy { it.id }, key = { it.id }) { event ->
|
|
||||||
FeedNoteCard(
|
|
||||||
event = event,
|
|
||||||
relayManager = relayManager,
|
|
||||||
localCache = localCache,
|
|
||||||
account = account,
|
|
||||||
nwcConnection = nwcConnection,
|
|
||||||
onReply = onCompose,
|
|
||||||
onZapFeedback = onZapFeedback,
|
|
||||||
onNavigateToProfile = onNavigateToProfile,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lightbox overlay
|
||||||
|
lightboxState?.let { (urls, index) ->
|
||||||
|
LightboxOverlay(
|
||||||
|
urls = urls,
|
||||||
|
initialIndex = index,
|
||||||
|
onDismiss = { lightboxState = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Edit Profile Dialog
|
// Edit Profile Dialog
|
||||||
if (showEditDialog && account != null) {
|
if (showEditDialog && account != null) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
|
|||||||
Reference in New Issue
Block a user