From 146e4e553306d746f8df87853649a9b7b23e3517 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 2 Jan 2026 07:51:31 +0200 Subject: [PATCH] initial desktop phase1 --- .../commons/network/RelayConnectionManager.kt | 19 + .../amethyst/commons/ui/note/NoteCard.kt | 11 +- .../amethyst/commons/util/EventExtensions.kt | 1 + .../vitorpamplona/amethyst/desktop/Main.kt | 170 ++++- .../amethyst/desktop/ui/ComposeNoteDialog.kt | 227 +++++++ .../amethyst/desktop/ui/FeedScreen.kt | 280 ++++++++- .../amethyst/desktop/ui/NoteActions.kt | 212 +++++++ .../desktop/ui/NotificationsScreen.kt | 305 +++++++++ .../amethyst/desktop/ui/UserProfileScreen.kt | 587 ++++++++++++++++++ 9 files changed, 1748 insertions(+), 64 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayConnectionManager.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayConnectionManager.kt index 45f9571c2..b09c662ae 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayConnectionManager.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayConnectionManager.kt @@ -99,6 +99,25 @@ open class RelayConnectionManager( client.send(event, relays) } + /** + * Broadcasts an event to all configured relays. + * Events will be sent to relays as they become connected. + */ + fun broadcastToAll(event: Event) { + // Use all configured relays, not just currently connected ones + // The NostrClient will queue/send events as relays connect + val configuredRelays = relayStatuses.value.keys + val connectedCount = connectedRelays.value.size + println("[RelayManager] broadcastToAll: event kind=${event.kind}, ID=${event.id.take(8)}...") + println("[RelayManager] Configured relays: ${configuredRelays.size}, Connected: $connectedCount") + configuredRelays.forEach { relay -> + val isConnected = connectedRelays.value.contains(relay) + println("[RelayManager] - $relay: ${if (isConnected) "CONNECTED" else "pending"}") + } + send(event, configuredRelays) + println("[RelayManager] broadcastToAll complete") + } + private fun updateRelayStatus( url: NormalizedRelayUrl, update: (RelayStatus) -> RelayStatus, diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCard.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCard.kt index a82e4faf8..7a05268b2 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCard.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCard.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.ui.note +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -50,6 +51,7 @@ import com.vitorpamplona.amethyst.commons.util.toTimeAgo */ data class NoteDisplayData( val id: String, + val pubKeyHex: String, val pubKeyDisplay: String, val content: String, val createdAt: Long, @@ -64,6 +66,7 @@ fun NoteCard( note: NoteDisplayData, modifier: Modifier = Modifier, onClick: (() -> Unit)? = null, + onAuthorClick: ((String) -> Unit)? = null, ) { val richTextParser = remember { RichTextParser() } val urls = remember(note.content) { richTextParser.parseValidUrls(note.content) } @@ -82,12 +85,18 @@ fun NoteCard( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - // Author (truncated) + // Author (truncated, clickable) Text( text = note.pubKeyDisplay.take(20) + if (note.pubKeyDisplay.length > 20) "..." else "", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary, maxLines = 1, + modifier = + if (onAuthorClick != null) { + Modifier.clickable { onAuthorClick(note.pubKeyHex) } + } else { + Modifier + }, ) // Timestamp diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/EventExtensions.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/EventExtensions.kt index 1f0de406e..0c392b8f8 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/EventExtensions.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/EventExtensions.kt @@ -38,6 +38,7 @@ fun Event.toNoteDisplayData(): NoteDisplayData { return NoteDisplayData( id = id, + pubKeyHex = pubKey, pubKeyDisplay = npub, content = content, createdAt = createdAt, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 7e91eb32d..a04407ee9 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -81,8 +81,32 @@ import com.vitorpamplona.amethyst.commons.ui.screens.MessagesPlaceholder import com.vitorpamplona.amethyst.commons.ui.screens.NotificationsPlaceholder import com.vitorpamplona.amethyst.commons.ui.screens.SearchPlaceholder import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.ui.ComposeNoteDialog import com.vitorpamplona.amethyst.desktop.ui.FeedScreen import com.vitorpamplona.amethyst.desktop.ui.LoginScreen +import com.vitorpamplona.amethyst.desktop.ui.NotificationsScreen +import com.vitorpamplona.amethyst.desktop.ui.UserProfileScreen + +private val isMacOS = System.getProperty("os.name").lowercase().contains("mac") + +/** + * Desktop navigation state - extends AppScreen with dynamic destinations. + */ +sealed class DesktopScreen { + data object Feed : DesktopScreen() + + data object Search : DesktopScreen() + + data object Messages : DesktopScreen() + + data object Notifications : DesktopScreen() + + data object MyProfile : DesktopScreen() + + data class UserProfile(val pubKeyHex: String) : DesktopScreen() + + data object Settings : DesktopScreen() +} fun main() = application { @@ -92,6 +116,7 @@ fun main() = height = 800.dp, position = WindowPosition.Aligned(Alignment.Center), ) + var showComposeDialog by remember { mutableStateOf(false) } Window( onCloseRequest = ::exitApplication, @@ -102,25 +127,58 @@ fun main() = Menu("File") { Item( "New Note", - shortcut = KeyShortcut(Key.N, ctrl = true), - onClick = { /* TODO: Open new note dialog */ }, + shortcut = + if (isMacOS) { + KeyShortcut(Key.N, meta = true) + } else { + KeyShortcut(Key.N, ctrl = true) + }, + onClick = { showComposeDialog = true }, ) Separator() Item( "Settings", - shortcut = KeyShortcut(Key.Comma, ctrl = true), + shortcut = + if (isMacOS) { + KeyShortcut(Key.Comma, meta = true) + } else { + KeyShortcut(Key.Comma, ctrl = true) + }, onClick = { /* TODO: Open settings */ }, ) Separator() Item( "Quit", - shortcut = KeyShortcut(Key.Q, ctrl = true), + shortcut = + if (isMacOS) { + KeyShortcut(Key.Q, meta = true) + } else { + KeyShortcut(Key.Q, ctrl = true) + }, onClick = ::exitApplication, ) } Menu("Edit") { - Item("Copy", shortcut = KeyShortcut(Key.C, ctrl = true), onClick = { }) - Item("Paste", shortcut = KeyShortcut(Key.V, ctrl = true), onClick = { }) + Item( + "Copy", + shortcut = + if (isMacOS) { + KeyShortcut(Key.C, meta = true) + } else { + KeyShortcut(Key.C, ctrl = true) + }, + onClick = { }, + ) + Item( + "Paste", + shortcut = + if (isMacOS) { + KeyShortcut(Key.V, meta = true) + } else { + KeyShortcut(Key.V, ctrl = true) + }, + onClick = { }, + ) } Menu("View") { Item("Feed", onClick = { }) @@ -133,13 +191,21 @@ fun main() = } } - App() + App( + showComposeDialog = showComposeDialog, + onShowComposeDialog = { showComposeDialog = true }, + onDismissComposeDialog = { showComposeDialog = false }, + ) } } @Composable -fun App() { - var currentScreen by remember { mutableStateOf(AppScreen.Feed) } +fun App( + showComposeDialog: Boolean, + onShowComposeDialog: () -> Unit, + onDismissComposeDialog: () -> Unit, +) { + var currentScreen by remember { mutableStateOf(DesktopScreen.Feed) } val relayManager = remember { DesktopRelayConnectionManager() } val accountManager = remember { AccountManager() } val accountState by accountManager.accountState.collectAsState() @@ -163,17 +229,29 @@ fun App() { is AccountState.LoggedOut -> { LoginScreen( accountManager = accountManager, - onLoginSuccess = { currentScreen = AppScreen.Feed }, + onLoginSuccess = { currentScreen = DesktopScreen.Feed }, ) } is AccountState.LoggedIn -> { + val account = accountState as AccountState.LoggedIn + MainContent( currentScreen = currentScreen, onScreenChange = { currentScreen = it }, relayManager = relayManager, accountManager = accountManager, - account = accountState as AccountState.LoggedIn, + account = account, + onShowComposeDialog = onShowComposeDialog, ) + + // Compose dialog + if (showComposeDialog) { + ComposeNoteDialog( + onDismiss = onDismissComposeDialog, + relayManager = relayManager, + account = account, + ) + } } } } @@ -182,11 +260,12 @@ fun App() { @Composable fun MainContent( - currentScreen: AppScreen, - onScreenChange: (AppScreen) -> Unit, + currentScreen: DesktopScreen, + onScreenChange: (DesktopScreen) -> Unit, relayManager: DesktopRelayConnectionManager, accountManager: AccountManager, account: AccountState.LoggedIn, + onShowComposeDialog: () -> Unit, ) { Row(Modifier.fillMaxSize()) { // Sidebar Navigation @@ -199,36 +278,36 @@ fun MainContent( NavigationRailItem( icon = { Icon(Icons.Default.Home, contentDescription = "Feed") }, label = { Text("Feed") }, - selected = currentScreen == AppScreen.Feed, - onClick = { onScreenChange(AppScreen.Feed) }, + selected = currentScreen == DesktopScreen.Feed, + onClick = { onScreenChange(DesktopScreen.Feed) }, ) NavigationRailItem( icon = { Icon(Icons.Default.Search, contentDescription = "Search") }, label = { Text("Search") }, - selected = currentScreen == AppScreen.Search, - onClick = { onScreenChange(AppScreen.Search) }, + selected = currentScreen == DesktopScreen.Search, + onClick = { onScreenChange(DesktopScreen.Search) }, ) NavigationRailItem( icon = { Icon(Icons.Default.Email, contentDescription = "Messages") }, label = { Text("DMs") }, - selected = currentScreen == AppScreen.Messages, - onClick = { onScreenChange(AppScreen.Messages) }, + selected = currentScreen == DesktopScreen.Messages, + onClick = { onScreenChange(DesktopScreen.Messages) }, ) NavigationRailItem( icon = { Icon(Icons.Default.Notifications, contentDescription = "Notifications") }, label = { Text("Alerts") }, - selected = currentScreen == AppScreen.Notifications, - onClick = { onScreenChange(AppScreen.Notifications) }, + selected = currentScreen == DesktopScreen.Notifications, + onClick = { onScreenChange(DesktopScreen.Notifications) }, ) NavigationRailItem( icon = { Icon(Icons.Default.Person, contentDescription = "Profile") }, label = { Text("Profile") }, - selected = currentScreen == AppScreen.Profile, - onClick = { onScreenChange(AppScreen.Profile) }, + selected = currentScreen == DesktopScreen.MyProfile || currentScreen is DesktopScreen.UserProfile, + onClick = { onScreenChange(DesktopScreen.MyProfile) }, ) Spacer(Modifier.weight(1f)) @@ -238,8 +317,8 @@ fun MainContent( NavigationRailItem( icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") }, label = { Text("Settings") }, - selected = currentScreen == AppScreen.Settings, - onClick = { onScreenChange(AppScreen.Settings) }, + selected = currentScreen == DesktopScreen.Settings, + onClick = { onScreenChange(DesktopScreen.Settings) }, ) Spacer(Modifier.height(16.dp)) @@ -252,12 +331,41 @@ fun MainContent( modifier = Modifier.weight(1f).fillMaxHeight().padding(24.dp), ) { when (currentScreen) { - AppScreen.Feed -> FeedScreen(relayManager) - AppScreen.Search -> SearchPlaceholder() - AppScreen.Messages -> MessagesPlaceholder() - AppScreen.Notifications -> NotificationsPlaceholder() - AppScreen.Profile -> ProfileScreen(account, accountManager) - AppScreen.Settings -> RelaySettingsScreen(relayManager) + DesktopScreen.Feed -> + FeedScreen( + relayManager = relayManager, + account = account, + onCompose = onShowComposeDialog, + onNavigateToProfile = { pubKeyHex -> + onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) + }, + ) + DesktopScreen.Search -> SearchPlaceholder() + DesktopScreen.Messages -> MessagesPlaceholder() + DesktopScreen.Notifications -> NotificationsScreen(relayManager, account) + DesktopScreen.MyProfile -> + UserProfileScreen( + pubKeyHex = account.pubKeyHex, + relayManager = relayManager, + account = account, + onBack = { onScreenChange(DesktopScreen.Feed) }, + onCompose = onShowComposeDialog, + onNavigateToProfile = { pubKeyHex -> + onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) + }, + ) + is DesktopScreen.UserProfile -> + UserProfileScreen( + pubKeyHex = currentScreen.pubKeyHex, + relayManager = relayManager, + account = account, + onBack = { onScreenChange(DesktopScreen.Feed) }, + onCompose = onShowComposeDialog, + onNavigateToProfile = { pubKeyHex -> + onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) + }, + ) + DesktopScreen.Settings -> RelaySettingsScreen(relayManager) } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt new file mode 100644 index 000000000..f9e48c53f --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt @@ -0,0 +1,227 @@ +/** + * 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.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.vitorpamplona.amethyst.commons.account.AccountState +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.events.eTag +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip01Core.tags.references.references +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip10Notes.content.findHashtags +import com.vitorpamplona.quartz.nip10Notes.content.findURLs +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@Composable +fun ComposeNoteDialog( + onDismiss: () -> Unit, + relayManager: DesktopRelayConnectionManager, + account: AccountState.LoggedIn, + replyTo: com.vitorpamplona.quartz.nip01Core.core.Event? = null, +) { + var content by remember { mutableStateOf("") } + var isPosting by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + + Dialog(onDismissRequest = { if (!isPosting) onDismiss() }) { + Card( + modifier = Modifier.width(600.dp).padding(16.dp), + ) { + Column(modifier = Modifier.padding(24.dp)) { + Text( + if (replyTo != null) "Reply" else "New Note", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + + replyTo?.let { reply -> + Spacer(Modifier.height(8.dp)) + Text( + "Replying to: ${reply.content.take(50)}${if (reply.content.length > 50) "..." else ""}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(Modifier.height(16.dp)) + + OutlinedTextField( + value = content, + onValueChange = { + content = it + errorMessage = null + }, + modifier = Modifier.fillMaxWidth().height(200.dp), + label = { Text("What's on your mind?") }, + placeholder = { Text("Write your note...") }, + enabled = !isPosting, + maxLines = 10, + ) + + Spacer(Modifier.height(8.dp)) + + // Character count + Text( + "${content.length} characters", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + errorMessage?.let { error -> + Spacer(Modifier.height(8.dp)) + Text( + error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + + Spacer(Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + OutlinedButton( + onClick = onDismiss, + enabled = !isPosting, + ) { + Text("Cancel") + } + + Spacer(Modifier.width(8.dp)) + + Button( + onClick = { + if (content.isBlank()) { + errorMessage = "Note cannot be empty" + return@Button + } + + scope.launch { + isPosting = true + errorMessage = null + + try { + publishNote( + content = content, + account = account, + relayManager = relayManager, + replyTo = replyTo, + ) + onDismiss() + } catch (e: Exception) { + errorMessage = "Failed to publish: ${e.message}" + } finally { + isPosting = false + } + } + }, + enabled = !isPosting && content.isNotBlank(), + ) { + Text(if (isPosting) "Publishing..." else "Publish") + } + } + } + } + } +} + +/** + * Publishes a text note to relays. + * Uses the Account's key to sign the event. + */ +private suspend fun publishNote( + content: String, + account: AccountState.LoggedIn, + relayManager: DesktopRelayConnectionManager, + replyTo: com.vitorpamplona.quartz.nip01Core.core.Event?, +) { + withContext(Dispatchers.IO) { + println("[ComposeNoteDialog] Starting publishNote: content length=${content.length}") + + // Build TextNoteEvent using Quartz + val template = + TextNoteEvent.build(content) { + // If replying, add e-tag and p-tag + if (replyTo != null) { + val etag = ETag(replyTo.id) + etag.relay = null + etag.author = replyTo.pubKey + eTag(etag) + pTag(PTag(replyTo.pubKey, relayHint = null)) + } + + // Extract hashtags and URLs from content + hashtags(findHashtags(content)) + references(findURLs(content)) + } + + // Sign the event + val signedEvent = + if (account.isReadOnly) { + // Read-only mode - can't sign events + println("[ComposeNoteDialog] Error: Cannot post in read-only mode") + throw IllegalStateException("Cannot post in read-only mode") + } else { + // Sign with nsec (full key) + val signer = account.signer + println("[ComposeNoteDialog] Signing event with pubkey: ${account.pubKeyHex.take(8)}...") + signer.sign(template) + } + + println("[ComposeNoteDialog] Event signed successfully, ID: ${signedEvent.id.take(8)}...") + + // Broadcast to all configured relays + println("[ComposeNoteDialog] Broadcasting to relays...") + relayManager.broadcastToAll(signedEvent) + println("[ComposeNoteDialog] Broadcast complete") + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 9eb70e313..941def5ee 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -22,19 +22,37 @@ package com.vitorpamplona.amethyst.desktop.ui import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +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 +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Button +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.account.AccountState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState import com.vitorpamplona.amethyst.commons.ui.feed.FeedHeader import com.vitorpamplona.amethyst.commons.ui.note.NoteCard @@ -44,35 +62,141 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +/** + * Note card with action buttons. + */ @Composable -fun FeedScreen(relayManager: DesktopRelayConnectionManager) { +fun FeedNoteCard( + event: Event, + relayManager: DesktopRelayConnectionManager, + account: AccountState.LoggedIn?, + onReply: () -> Unit, + onNavigateToProfile: (String) -> Unit = {}, +) { + Column { + NoteCard( + note = event.toNoteDisplayData(), + onAuthorClick = onNavigateToProfile, + ) + + // Action buttons (only if logged in) + if (account != null) { + NoteActionsRow( + event = event, + relayManager = relayManager, + account = account, + onReplyClick = onReply, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + ) + } + } +} + +enum class FeedMode { + GLOBAL, + FOLLOWING, +} + +@Composable +fun FeedScreen( + relayManager: DesktopRelayConnectionManager, + account: AccountState.LoggedIn? = null, + onCompose: () -> Unit = {}, + onNavigateToProfile: (String) -> Unit = {}, +) { val connectedRelays by relayManager.connectedRelays.collectAsState() val relayStatuses by relayManager.relayStatuses.collectAsState() val events = remember { mutableStateListOf() } val seenIds = remember { mutableSetOf() } + var replyToEvent by remember { mutableStateOf(null) } + var feedMode by remember { mutableStateOf(FeedMode.GLOBAL) } + var followedUsers by remember { mutableStateOf>(emptySet()) } - // Use relayStatuses.keys (configured relays) to initiate subscription, - // not connectedRelays (which is empty until subscriptions trigger connections) - val configuredRelays = relayStatuses.keys - - DisposableEffect(configuredRelays) { - if (configuredRelays.isNotEmpty()) { - val subId = "global-feed-${System.currentTimeMillis()}" - val filters = - listOf( - Filter( - kinds = listOf(TextNoteEvent.KIND), - limit = 50, - ), - ) - + // Load followed users for Following feed mode + DisposableEffect(relayStatuses, account, feedMode) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isNotEmpty() && account != null && feedMode == FeedMode.FOLLOWING) { + val contactListSubId = "feed-contacts-${account.pubKeyHex}-${System.currentTimeMillis()}" relayManager.subscribe( - subId = subId, - filters = filters, + subId = contactListSubId, + filters = + listOf( + Filter( + kinds = listOf(ContactListEvent.KIND), + authors = listOf(account.pubKeyHex), + limit = 1, + ), + ), relays = configuredRelays, listener = + object : IRequestListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (event is ContactListEvent) { + followedUsers = event.verifiedFollowKeySet() + } + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) {} + }, + ) + + onDispose { + relayManager.unsubscribe(contactListSubId) + } + } else { + onDispose {} + } + } + + DisposableEffect(relayStatuses, feedMode, followedUsers) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isNotEmpty()) { + // Clear previous events when switching modes + events.clear() + seenIds.clear() + + val subId = "${feedMode.name.lowercase()}-feed-${System.currentTimeMillis()}" + val filters = + when (feedMode) { + FeedMode.GLOBAL -> + listOf( + Filter( + kinds = listOf(TextNoteEvent.KIND), + limit = 50, + ), + ) + FeedMode.FOLLOWING -> + if (followedUsers.isNotEmpty()) { + listOf( + Filter( + kinds = listOf(TextNoteEvent.KIND), + authors = followedUsers.toList(), + limit = 50, + ), + ) + } else { + // No followed users yet, return empty filter + emptyList() + } + } + + if (filters.isNotEmpty()) { + relayManager.subscribe( + subId = subId, + filters = filters, + relays = configuredRelays, + listener = object : IRequestListener { override fun onEvent( event: Event, @@ -97,40 +221,132 @@ fun FeedScreen(relayManager: DesktopRelayConnectionManager) { // End of stored events } }, - ) + ) - onDispose { - relayManager.unsubscribe(subId) + onDispose { + relayManager.unsubscribe(subId) + } + } else { + onDispose {} } } else { - onDispose { } + onDispose {} } } Column(modifier = Modifier.fillMaxSize()) { - FeedHeader( - title = "Global Feed", - connectedRelayCount = connectedRelays.size, - onRefresh = { relayManager.connect() }, - ) + // Header with compose button + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + if (feedMode == FeedMode.GLOBAL) "Global Feed" else "Following Feed", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + ) - Spacer(Modifier.height(16.dp)) + // Feed mode selector + if (account != null) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + FilterChip( + selected = feedMode == FeedMode.GLOBAL, + onClick = { feedMode = FeedMode.GLOBAL }, + label = { Text("Global") }, + ) + FilterChip( + selected = feedMode == FeedMode.FOLLOWING, + onClick = { feedMode = FeedMode.FOLLOWING }, + label = { Text("Following") }, + ) + } + } + } + + Spacer(Modifier.height(4.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "${connectedRelays.size} relays connected", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (feedMode == FeedMode.FOLLOWING) { + Text( + " • ${followedUsers.size} followed", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.width(8.dp)) + IconButton( + onClick = { relayManager.connect() }, + modifier = Modifier.size(24.dp), + ) { + Icon( + Icons.Default.Refresh, + contentDescription = "Refresh", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + } + } + } + + // New Post button (primary action) + Button( + onClick = onCompose, + enabled = account != null && !account.isReadOnly, + ) { + Icon(Icons.Default.Add, "New Post", Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("New Post") + } + } + + Spacer(Modifier.height(8.dp)) if (connectedRelays.isEmpty()) { LoadingState("Connecting to relays...") + } else if (feedMode == FeedMode.FOLLOWING && followedUsers.isEmpty()) { + LoadingState("Loading followed users...") } else if (events.isEmpty()) { - LoadingState("Loading notes...") + LoadingState( + if (feedMode == FeedMode.FOLLOWING) { + "No notes from followed users yet" + } else { + "Loading notes..." + }, + ) } else { LazyColumn( verticalArrangement = Arrangement.spacedBy(8.dp), ) { items(events, key = { it.id }) { event -> - // Use NoteCard from commons - NoteCard( - note = event.toNoteDisplayData(), + FeedNoteCard( + event = event, + relayManager = relayManager, + account = account, + onReply = { replyToEvent = event }, + onNavigateToProfile = onNavigateToProfile, ) } } } + + // Reply dialog + if (replyToEvent != null && account != null) { + ComposeNoteDialog( + onDismiss = { replyToEvent = null }, + relayManager = relayManager, + account = account, + replyTo = replyToEvent, + ) + } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt new file mode 100644 index 000000000..b630d2c62 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt @@ -0,0 +1,212 @@ +/** + * 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.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.outlined.FavoriteBorder +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.account.AccountState +import com.vitorpamplona.amethyst.commons.icons.Reply +import com.vitorpamplona.amethyst.commons.icons.Repost +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Action buttons row for a note (react, reply, repost). + */ +@Composable +fun NoteActionsRow( + event: Event, + relayManager: DesktopRelayConnectionManager, + account: AccountState.LoggedIn, + onReplyClick: () -> Unit, + modifier: Modifier = Modifier, +) { + var isLiked by remember { mutableStateOf(false) } + var isReposted by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Reply button + IconButton( + onClick = onReplyClick, + modifier = Modifier.size(32.dp), + ) { + Icon( + Reply, + contentDescription = "Reply", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + } + + // Like button + IconButton( + onClick = { + if (!isLiked) { + scope.launch { + reactToNote( + event = event, + reaction = "+", + account = account, + relayManager = relayManager, + ) + isLiked = true + } + } + }, + modifier = Modifier.size(32.dp), + ) { + Icon( + if (isLiked) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder, + contentDescription = if (isLiked) "Unlike" else "Like", + tint = + if (isLiked) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.size(18.dp), + ) + } + + // Repost button + IconButton( + onClick = { + if (!isReposted) { + scope.launch { + repostNote( + event = event, + account = account, + relayManager = relayManager, + ) + isReposted = true + } + } + }, + modifier = Modifier.size(32.dp), + ) { + Icon( + Repost, + contentDescription = "Repost", + tint = + if (isReposted) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.size(18.dp), + ) + } + + // Placeholder for action count + Text( + "", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** + * Creates a reaction event and broadcasts to relays. + */ +private suspend fun reactToNote( + event: Event, + reaction: String, + account: AccountState.LoggedIn, + relayManager: DesktopRelayConnectionManager, +) { + withContext(Dispatchers.IO) { + // Build reaction event + val reactedTo = EventHintBundle(event, null) + val template = ReactionEvent.like(reactedTo) + + // Sign with user's key + val signedEvent = account.signer.sign(template) + + // Broadcast to all relays + relayManager.broadcastToAll(signedEvent) + } +} + +/** + * Creates a repost event and broadcasts to relays. + */ +private suspend fun repostNote( + event: Event, + account: AccountState.LoggedIn, + relayManager: DesktopRelayConnectionManager, +) { + withContext(Dispatchers.IO) { + // Build repost event + val template = + if (event.kind == 1) { + // Text note - use RepostEvent (kind 6) + RepostEvent.build( + boostedPost = event, + eventSourceRelay = null, + authorHomeRelay = null, + ) + } else { + // Other kinds - use GenericRepostEvent (kind 16) + GenericRepostEvent.build( + boostedPost = event, + eventSourceRelay = null, + authorHomeRelay = null, + ) + } + + // Sign with user's key + val signedEvent = account.signer.sign(template) + + // Broadcast to all relays + relayManager.broadcastToAll(signedEvent) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt new file mode 100644 index 000000000..00a3d01ff --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt @@ -0,0 +1,305 @@ +/** + * 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.Arrangement +import androidx.compose.foundation.layout.Column +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 +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.account.AccountState +import com.vitorpamplona.amethyst.commons.icons.Reply +import com.vitorpamplona.amethyst.commons.icons.Repost +import com.vitorpamplona.amethyst.commons.icons.Zap +import com.vitorpamplona.amethyst.commons.ui.components.LoadingState +import com.vitorpamplona.amethyst.commons.ui.feed.FeedHeader +import com.vitorpamplona.amethyst.commons.util.toNoteDisplayData +import com.vitorpamplona.amethyst.commons.util.toTimeAgo +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip19Bech32.toNpub +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent + +/** + * Notification types for display. + */ +sealed class NotificationItem( + open val event: Event, + open val timestamp: Long, +) { + data class Mention(override val event: Event, override val timestamp: Long) : NotificationItem(event, timestamp) + + data class Reply(override val event: Event, override val timestamp: Long) : NotificationItem(event, timestamp) + + data class Reaction(override val event: Event, override val timestamp: Long, val content: String) : + NotificationItem(event, timestamp) + + data class Repost(override val event: Event, override val timestamp: Long) : NotificationItem(event, timestamp) + + data class Zap(override val event: Event, override val timestamp: Long, val amount: Long?) : + NotificationItem(event, timestamp) +} + +@Composable +fun NotificationsScreen( + relayManager: DesktopRelayConnectionManager, + account: AccountState.LoggedIn, +) { + val connectedRelays by relayManager.connectedRelays.collectAsState() + val relayStatuses by relayManager.relayStatuses.collectAsState() + val notifications = remember { mutableStateListOf() } + val seenIds = remember { mutableSetOf() } + + DisposableEffect(relayStatuses, account.pubKeyHex) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isNotEmpty()) { + val subId = "notifications-${account.pubKeyHex}-${System.currentTimeMillis()}" + val filters = + listOf( + // Mentions, replies, reactions, reposts, zaps + Filter( + kinds = + listOf( + TextNoteEvent.KIND, // 1 - mentions/replies + ReactionEvent.KIND, // 7 - reactions + RepostEvent.KIND, // 6 - reposts + GenericRepostEvent.KIND, // 16 - generic reposts + LnZapEvent.KIND, // 9735 - zaps + ), + tags = mapOf("p" to listOf(account.pubKeyHex)), // Events mentioning user + limit = 100, + ), + ) + + relayManager.subscribe( + subId = subId, + filters = filters, + relays = configuredRelays, + listener = + object : IRequestListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + // Skip events from the user themselves (except zaps) + if (event.pubKey == account.pubKeyHex && event !is LnZapEvent) { + return + } + + if (event.id !in seenIds) { + seenIds.add(event.id) + + val notification = + when (event) { + is ReactionEvent -> + NotificationItem.Reaction( + event = event, + timestamp = event.createdAt, + content = event.content, + ) + is RepostEvent, is GenericRepostEvent -> + NotificationItem.Repost( + event = event, + timestamp = event.createdAt, + ) + is LnZapEvent -> { + // Extract amount from zap (simplified - full parsing in production) + val amount = event.amount?.toLong() + NotificationItem.Zap( + event = event, + timestamp = event.createdAt, + amount = amount, + ) + } + is TextNoteEvent -> { + // Check if it's a reply (has e-tag) or mention + val eTags = event.tags.filter { it.size > 1 && it[0] == "e" } + val isReply = eTags.isNotEmpty() + if (isReply) { + NotificationItem.Reply(event, event.createdAt) + } else { + NotificationItem.Mention(event, event.createdAt) + } + } + else -> NotificationItem.Mention(event, event.createdAt) + } + + notifications.add(0, notification) + if (notifications.size > 200) { + val removed = notifications.removeAt(notifications.size - 1) + seenIds.remove(removed.event.id) + } + } + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + // End of stored events + } + }, + ) + + onDispose { + relayManager.unsubscribe(subId) + } + } else { + onDispose { } + } + } + + Column(modifier = Modifier.fillMaxSize()) { + FeedHeader( + title = "Notifications", + connectedRelayCount = connectedRelays.size, + onRefresh = { relayManager.connect() }, + ) + + Spacer(Modifier.height(16.dp)) + + if (connectedRelays.isEmpty()) { + LoadingState("Connecting to relays...") + } else if (notifications.isEmpty()) { + LoadingState("Loading notifications...") + } else { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(notifications, key = { it.event.id }) { notification -> + NotificationCard(notification) + } + } + } + } +} + +@Composable +fun NotificationCard(notification: NotificationItem) { + val (icon, label, color) = + when (notification) { + is NotificationItem.Mention -> Triple(Icons.Default.Favorite, "mentioned you", MaterialTheme.colorScheme.primary) + is NotificationItem.Reply -> Triple(Reply, "replied", MaterialTheme.colorScheme.secondary) + is NotificationItem.Reaction -> + Triple( + Icons.Default.Favorite, + "reacted ${notification.content}", + MaterialTheme.colorScheme.tertiary, + ) + is NotificationItem.Repost -> Triple(Repost, "reposted", MaterialTheme.colorScheme.primary) + is NotificationItem.Zap -> { + val amountText = notification.amount?.let { " ${it / 1000} sats" } ?: "" + Triple(Zap, "zapped$amountText", MaterialTheme.colorScheme.primary) + } + } + + val authorDisplay = + try { + notification.event.pubKey.hexToByteArrayOrNull()?.toNpub()?.take(20) ?: notification.event.pubKey.take(20) + } catch (e: Exception) { + notification.event.pubKey.take(20) + } + + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Column(modifier = Modifier.padding(12.dp)) { + // Header: icon + label + author + time + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + icon, + contentDescription = label, + tint = color, + modifier = Modifier.size(16.dp), + ) + Text( + text = "$authorDisplay $label", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Text( + text = notification.timestamp.toTimeAgo(withDot = false), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + } + + // Content (for text-based notifications) + if (notification is NotificationItem.Mention || + notification is NotificationItem.Reply + ) { + Spacer(Modifier.height(8.dp)) + Text( + text = notification.event.content.take(200), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 3, + ) + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt new file mode 100644 index 000000000..a2c9ff798 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -0,0 +1,587 @@ +/** + * 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.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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 +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.PersonAdd +import androidx.compose.material.icons.filled.PersonRemove +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.account.AccountState +import com.vitorpamplona.amethyst.commons.ui.components.LoadingState +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull +import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip02FollowList.ReadWrite +import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip19Bech32.toNpub +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * User profile screen showing user info, follow button, and their posts. + */ +@Composable +fun UserProfileScreen( + pubKeyHex: String, + relayManager: DesktopRelayConnectionManager, + account: AccountState.LoggedIn?, + onBack: () -> Unit, + onCompose: () -> Unit = {}, + onNavigateToProfile: (String) -> Unit = {}, +) { + val connectedRelays by relayManager.connectedRelays.collectAsState() + val relayStatuses by relayManager.relayStatuses.collectAsState() + + // User metadata + var displayName by remember { mutableStateOf(null) } + var about by remember { mutableStateOf(null) } + var picture by remember { mutableStateOf(null) } + var followersCount by remember { mutableStateOf(0) } + var followingCount by remember { mutableStateOf(0) } + + // User's posts + val events = remember { mutableStateListOf() } + val seenIds = remember { mutableSetOf() } + var postsLoading by remember { mutableStateOf(true) } + var postsError by remember { mutableStateOf(null) } + var retryTrigger by remember { mutableStateOf(0) } + + // Follow state + var isFollowing by remember { mutableStateOf(false) } + var currentContactList by remember { mutableStateOf(null) } + var isFollowLoading by remember { mutableStateOf(false) } + var followError by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + + // Load current user's contact list (for follow state) + DisposableEffect(relayStatuses, account) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isNotEmpty() && account != null) { + val contactListSubId = "my-contacts-${account.pubKeyHex}-${System.currentTimeMillis()}" + relayManager.subscribe( + subId = contactListSubId, + filters = + listOf( + Filter( + kinds = listOf(ContactListEvent.KIND), // Kind 3 + authors = listOf(account.pubKeyHex), + limit = 1, + ), + ), + relays = configuredRelays, + listener = + object : IRequestListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (event is ContactListEvent) { + currentContactList = event + isFollowing = event.isTaggedUser(pubKeyHex) + } + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) {} + }, + ) + + onDispose { + relayManager.unsubscribe(contactListSubId) + } + } else { + onDispose {} + } + } + + // Subscribe to user metadata and posts + DisposableEffect(relayStatuses, pubKeyHex, retryTrigger) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isNotEmpty()) { + postsLoading = true + postsError = null + events.clear() + seenIds.clear() + // Metadata subscription (kind 0) + val metadataSubId = "profile-metadata-$pubKeyHex-${System.currentTimeMillis()}" + relayManager.subscribe( + subId = metadataSubId, + filters = + listOf( + Filter( + kinds = listOf(0), // Metadata + authors = listOf(pubKeyHex), + limit = 1, + ), + ), + relays = configuredRelays, + listener = + object : IRequestListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + // Parse metadata JSON (simplified - full parsing in production) + try { + val content = event.content + displayName = extractJsonField(content, "display_name") ?: extractJsonField(content, "name") + about = extractJsonField(content, "about") + picture = extractJsonField(content, "picture") + } catch (e: Exception) { + // Ignore parse errors + } + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) {} + }, + ) + + // Posts subscription (kind 1) + val postsSubId = "profile-posts-$pubKeyHex-${System.currentTimeMillis()}" + relayManager.subscribe( + subId = postsSubId, + filters = + listOf( + Filter( + kinds = listOf(TextNoteEvent.KIND), + authors = listOf(pubKeyHex), + limit = 50, + ), + ), + relays = configuredRelays, + listener = + object : IRequestListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (event.id !in seenIds) { + seenIds.add(event.id) + events.add(0, event) + if (events.size > 100) { + val removed = events.removeAt(events.size - 1) + seenIds.remove(removed.id) + } + } + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + // At least one relay finished sending events + postsLoading = false + } + }, + ) + + // Set timeout for loading state + val timeoutJob = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Default).launch { + kotlinx.coroutines.delay(10000) // 10 second timeout + if (postsLoading) { + postsError = "Request timed out. Check relay connections." + postsLoading = false + } + } + + onDispose { + timeoutJob.cancel() + relayManager.unsubscribe(metadataSubId) + relayManager.unsubscribe(postsSubId) + } + } else { + postsLoading = false + postsError = "No relays configured" + onDispose {} + } + } + + Column(modifier = Modifier.fillMaxSize()) { + // Header with back button + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") + } + Spacer(Modifier.width(8.dp)) + Text( + "Profile", + style = MaterialTheme.typography.headlineMedium, + ) + } + + if (account != null && !account.isReadOnly && pubKeyHex != account.pubKeyHex) { + Column(horizontalAlignment = Alignment.End) { + Button( + onClick = { + scope.launch { + isFollowLoading = true + followError = null + try { + if (isFollowing) { + unfollowUser(pubKeyHex, account, relayManager, currentContactList) + isFollowing = false + } else { + followUser(pubKeyHex, account, relayManager, currentContactList) + isFollowing = true + } + } catch (e: Exception) { + followError = e.message ?: "Failed to update follow status" + } finally { + isFollowLoading = false + } + } + }, + enabled = !isFollowLoading, + ) { + if (isFollowLoading) { + 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") + } + } + + followError?.let { error -> + Spacer(Modifier.height(4.dp)) + Text( + error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + } + + if (connectedRelays.isEmpty()) { + LoadingState("Connecting to relays...") + } else { + // Profile card + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Column(modifier = Modifier.padding(24.dp)) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.Top, + ) { + // Profile picture placeholder + Surface( + modifier = Modifier.size(80.dp).clip(CircleShape), + color = MaterialTheme.colorScheme.primary, + ) { + // TODO: Load actual image from picture URL + } + + Column(modifier = Modifier.weight(1f)) { + Text( + displayName ?: (pubKeyHex.hexToByteArrayOrNull()?.toNpub()?.take(20) ?: pubKeyHex.take(20)), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.height(4.dp)) + Text( + (pubKeyHex.hexToByteArrayOrNull()?.toNpub()?.take(32) ?: pubKeyHex.take(32)) + "...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + if (about != null) { + Spacer(Modifier.height(16.dp)) + Text( + about!!, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + Spacer(Modifier.height(16.dp)) + + Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) { + Column { + Text( + "$followersCount", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text( + "Followers", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Column { + Text( + "$followingCount", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text( + "Following", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + + Spacer(Modifier.height(24.dp)) + + // User's posts + Text( + "Posts", + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(bottom = 8.dp), + ) + + when { + postsError != null -> { + // Error state with retry + 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 -> { + // Loading state + 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() -> { + // 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, key = { it.id }) { event -> + FeedNoteCard( + event = event, + relayManager = relayManager, + account = account, + onReply = onCompose, + onNavigateToProfile = onNavigateToProfile, + ) + } + } + } + } + } + } +} + +/** + * Simple JSON field extractor (not production-ready, just for demo). + */ +private fun extractJsonField( + json: String, + field: String, +): String? { + val regex = """"$field"\s*:\s*"([^"]*)"""".toRegex() + return regex.find(json)?.groupValues?.get(1) +} + +/** + * Follows a user by publishing an updated contact list event. + */ +private suspend fun followUser( + pubKeyHex: String, + account: AccountState.LoggedIn, + relayManager: DesktopRelayConnectionManager, + currentContactList: ContactListEvent?, +) { + withContext(Dispatchers.IO) { + println("[UserProfile] Starting followUser: target=${pubKeyHex.take(8)}...") + + val updatedEvent = + if (currentContactList != null) { + println("[UserProfile] Adding to existing contact list") + // Add to existing contact list + ContactListEvent.followUser( + earlierVersion = currentContactList, + pubKeyHex = pubKeyHex, + signer = account.signer, + ) + } else { + println("[UserProfile] Creating new contact list") + // Create new contact list with this user + ContactListEvent.createFromScratch( + followUsers = listOf(ContactTag(pubKeyHex)), + relayUse = emptyMap(), + signer = account.signer, + ) + } + + println("[UserProfile] ContactListEvent created, broadcasting...") + relayManager.broadcastToAll(updatedEvent) + println("[UserProfile] Follow broadcast complete") + } +} + +/** + * Unfollows a user by publishing an updated contact list event without them. + */ +private suspend fun unfollowUser( + pubKeyHex: String, + account: AccountState.LoggedIn, + relayManager: DesktopRelayConnectionManager, + currentContactList: ContactListEvent?, +) { + withContext(Dispatchers.IO) { + println("[UserProfile] Starting unfollowUser: target=${pubKeyHex.take(8)}...") + + if (currentContactList != null) { + println("[UserProfile] Removing from existing contact list") + val updatedEvent = + ContactListEvent.unfollowUser( + earlierVersion = currentContactList, + pubKeyHex = pubKeyHex, + signer = account.signer, + ) + + println("[UserProfile] ContactListEvent updated, broadcasting...") + relayManager.broadcastToAll(updatedEvent) + println("[UserProfile] Unfollow broadcast complete") + } else { + println("[UserProfile] Error: No contact list to unfollow from") + } + } +}