zap updates

This commit is contained in:
nrobi144
2026-01-19 16:32:01 +02:00
parent e1412c1f97
commit 8f6de1d304
30 changed files with 5267 additions and 205 deletions
@@ -0,0 +1,57 @@
/**
* 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
import com.vitorpamplona.amethyst.commons.subscriptions.FeedMode
import java.util.prefs.Preferences
/**
* Simple preferences storage using Java's Preferences API.
* Data is stored in platform-appropriate location:
* - macOS: ~/Library/Preferences/com.apple.java.util.prefs.plist
* - Linux: ~/.java/.userPrefs/
* - Windows: Registry under HKEY_CURRENT_USER\Software\JavaSoft\Prefs
*/
object DesktopPreferences {
private val prefs: Preferences = Preferences.userNodeForPackage(DesktopPreferences::class.java)
private const val KEY_FEED_MODE = "feed_mode"
private const val KEY_LAST_SCREEN = "last_screen"
var feedMode: FeedMode
get() {
val name = prefs.get(KEY_FEED_MODE, FeedMode.GLOBAL.name)
return try {
FeedMode.valueOf(name)
} catch (e: Exception) {
FeedMode.GLOBAL
}
}
set(value) {
prefs.put(KEY_FEED_MODE, value.name)
}
var lastScreen: String
get() = prefs.get(KEY_LAST_SCREEN, "Feed")
set(value) {
prefs.put(KEY_LAST_SCREEN, value)
}
}
@@ -34,6 +34,7 @@ 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.automirrored.filled.Article
import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Notifications
@@ -42,6 +43,7 @@ import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -50,12 +52,15 @@ import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -78,14 +83,19 @@ import com.vitorpamplona.amethyst.commons.account.AccountState
import com.vitorpamplona.amethyst.commons.ui.profile.ProfileInfoCard
import com.vitorpamplona.amethyst.commons.ui.relay.RelayStatusCard
import com.vitorpamplona.amethyst.commons.ui.screens.MessagesPlaceholder
import com.vitorpamplona.amethyst.commons.ui.screens.SearchPlaceholder
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.ui.BookmarksScreen
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.ReadsScreen
import com.vitorpamplona.amethyst.desktop.ui.SearchScreen
import com.vitorpamplona.amethyst.desktop.ui.ThreadScreen
import com.vitorpamplona.amethyst.desktop.ui.UserProfileScreen
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -99,8 +109,12 @@ private val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
sealed class DesktopScreen {
object Feed : DesktopScreen()
object Reads : DesktopScreen()
object Search : DesktopScreen()
object Bookmarks : DesktopScreen()
object Messages : DesktopScreen()
object Notifications : DesktopScreen()
@@ -127,6 +141,8 @@ fun main() =
position = WindowPosition.Aligned(Alignment.Center),
)
var showComposeDialog by remember { mutableStateOf(false) }
var replyToNote by remember { mutableStateOf<com.vitorpamplona.quartz.nip01Core.core.Event?>(null) }
var currentScreen by remember { mutableStateOf<DesktopScreen>(DesktopScreen.Feed) }
Window(
onCloseRequest = ::exitApplication,
@@ -154,7 +170,7 @@ fun main() =
} else {
KeyShortcut(Key.Comma, ctrl = true)
},
onClick = { /* TODO: Open settings */ },
onClick = { currentScreen = DesktopScreen.Settings },
)
Separator()
Item(
@@ -202,21 +218,35 @@ fun main() =
}
App(
currentScreen = currentScreen,
onScreenChange = { currentScreen = it },
showComposeDialog = showComposeDialog,
onShowComposeDialog = { showComposeDialog = true },
onDismissComposeDialog = { showComposeDialog = false },
onShowReplyDialog = { event ->
replyToNote = event
showComposeDialog = true
},
onDismissComposeDialog = {
showComposeDialog = false
replyToNote = null
},
replyToNote = replyToNote,
)
}
}
@Composable
fun App(
currentScreen: DesktopScreen,
onScreenChange: (DesktopScreen) -> Unit,
showComposeDialog: Boolean,
onShowComposeDialog: () -> Unit,
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
onDismissComposeDialog: () -> Unit,
replyToNote: com.vitorpamplona.quartz.nip01Core.core.Event?,
) {
var currentScreen by remember { mutableStateOf<DesktopScreen>(DesktopScreen.Feed) }
val relayManager = remember { DesktopRelayConnectionManager() }
val localCache = remember { DesktopLocalCache() }
val accountManager = remember { AccountManager.create() }
val accountState by accountManager.accountState.collectAsState()
val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) }
@@ -246,19 +276,28 @@ fun App(
is AccountState.LoggedOut -> {
LoginScreen(
accountManager = accountManager,
onLoginSuccess = { currentScreen = DesktopScreen.Feed },
onLoginSuccess = { onScreenChange(DesktopScreen.Feed) },
)
}
is AccountState.LoggedIn -> {
val account = accountState as AccountState.LoggedIn
val nwcConnection by accountManager.nwcConnection.collectAsState()
// Load NWC connection on first composition
LaunchedEffect(Unit) {
accountManager.loadNwcConnection()
}
MainContent(
currentScreen = currentScreen,
onScreenChange = { currentScreen = it },
onScreenChange = onScreenChange,
relayManager = relayManager,
localCache = localCache,
accountManager = accountManager,
account = account,
nwcConnection = nwcConnection,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
)
// Compose dialog
@@ -267,6 +306,7 @@ fun App(
onDismiss = onDismissComposeDialog,
relayManager = relayManager,
account = account,
replyTo = replyToNote,
)
}
}
@@ -280,127 +320,218 @@ fun MainContent(
currentScreen: DesktopScreen,
onScreenChange: (DesktopScreen) -> Unit,
relayManager: DesktopRelayConnectionManager,
localCache: DesktopLocalCache,
accountManager: AccountManager,
account: AccountState.LoggedIn,
nwcConnection: Nip47WalletConnect.Nip47URINorm?,
onShowComposeDialog: () -> Unit,
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
) {
Row(Modifier.fillMaxSize()) {
// Sidebar Navigation
NavigationRail(
modifier = Modifier.width(80.dp).fillMaxHeight(),
containerColor = MaterialTheme.colorScheme.surfaceVariant,
) {
Spacer(Modifier.height(16.dp))
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
NavigationRailItem(
icon = { Icon(Icons.Default.Home, contentDescription = "Feed") },
label = { Text("Feed") },
selected = currentScreen == DesktopScreen.Feed,
onClick = { onScreenChange(DesktopScreen.Feed) },
)
NavigationRailItem(
icon = { Icon(Icons.Default.Search, contentDescription = "Search") },
label = { Text("Search") },
selected = currentScreen == DesktopScreen.Search,
onClick = { onScreenChange(DesktopScreen.Search) },
)
NavigationRailItem(
icon = { Icon(Icons.Default.Email, contentDescription = "Messages") },
label = { Text("DMs") },
selected = currentScreen == DesktopScreen.Messages,
onClick = { onScreenChange(DesktopScreen.Messages) },
)
NavigationRailItem(
icon = { Icon(Icons.Default.Notifications, contentDescription = "Notifications") },
label = { Text("Alerts") },
selected = currentScreen == DesktopScreen.Notifications,
onClick = { onScreenChange(DesktopScreen.Notifications) },
)
NavigationRailItem(
icon = { Icon(Icons.Default.Person, contentDescription = "Profile") },
label = { Text("Profile") },
selected = currentScreen == DesktopScreen.MyProfile || currentScreen is DesktopScreen.UserProfile,
onClick = { onScreenChange(DesktopScreen.MyProfile) },
)
Spacer(Modifier.weight(1f))
HorizontalDivider(Modifier.padding(horizontal = 16.dp))
NavigationRailItem(
icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") },
label = { Text("Settings") },
selected = currentScreen == DesktopScreen.Settings,
onClick = { onScreenChange(DesktopScreen.Settings) },
)
Spacer(Modifier.height(16.dp))
val onZapFeedback: (ZapFeedback) -> Unit = { feedback ->
scope.launch {
val message =
when (feedback) {
is ZapFeedback.Success -> "Zapped ${feedback.amountSats} sats"
is ZapFeedback.ExternalWallet -> "Invoice sent to wallet (${feedback.amountSats} sats)"
is ZapFeedback.Error -> "Zap failed: ${feedback.message}"
is ZapFeedback.Timeout -> "Zap timed out"
is ZapFeedback.NoLightningAddress -> "User has no lightning address"
}
snackbarHostState.showSnackbar(message)
}
}
VerticalDivider()
Box(Modifier.fillMaxSize()) {
Row(Modifier.fillMaxSize()) {
// Sidebar Navigation
NavigationRail(
modifier = Modifier.width(80.dp).fillMaxHeight(),
containerColor = MaterialTheme.colorScheme.surfaceVariant,
) {
Spacer(Modifier.height(16.dp))
// Main Content
Box(
modifier = Modifier.weight(1f).fillMaxHeight().padding(24.dp),
) {
when (currentScreen) {
DesktopScreen.Feed ->
FeedScreen(
relayManager = relayManager,
account = account,
onCompose = onShowComposeDialog,
onNavigateToProfile = { pubKeyHex ->
onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
},
onNavigateToThread = { noteId ->
onScreenChange(DesktopScreen.Thread(noteId))
},
)
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))
},
)
is DesktopScreen.Thread ->
ThreadScreen(
noteId = currentScreen.noteId,
relayManager = relayManager,
account = account,
onBack = { onScreenChange(DesktopScreen.Feed) },
onNavigateToProfile = { pubKeyHex ->
onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
},
onNavigateToThread = { noteId ->
onScreenChange(DesktopScreen.Thread(noteId))
},
)
DesktopScreen.Settings -> RelaySettingsScreen(relayManager, account)
NavigationRailItem(
icon = { Icon(Icons.Default.Home, contentDescription = "Feed") },
label = { Text("Feed") },
selected = currentScreen == DesktopScreen.Feed,
onClick = { onScreenChange(DesktopScreen.Feed) },
)
NavigationRailItem(
icon = { Icon(Icons.AutoMirrored.Filled.Article, contentDescription = "Reads") },
label = { Text("Reads") },
selected = currentScreen == DesktopScreen.Reads,
onClick = { onScreenChange(DesktopScreen.Reads) },
)
NavigationRailItem(
icon = { Icon(Icons.Default.Search, contentDescription = "Search") },
label = { Text("Search") },
selected = currentScreen == DesktopScreen.Search,
onClick = { onScreenChange(DesktopScreen.Search) },
)
NavigationRailItem(
icon = { Icon(com.vitorpamplona.amethyst.commons.icons.Bookmark, contentDescription = "Bookmarks") },
label = { Text("Bookmarks") },
selected = currentScreen == DesktopScreen.Bookmarks,
onClick = { onScreenChange(DesktopScreen.Bookmarks) },
)
NavigationRailItem(
icon = { Icon(Icons.Default.Email, contentDescription = "Messages") },
label = { Text("DMs") },
selected = currentScreen == DesktopScreen.Messages,
onClick = { onScreenChange(DesktopScreen.Messages) },
)
NavigationRailItem(
icon = { Icon(Icons.Default.Notifications, contentDescription = "Notifications") },
label = { Text("Alerts") },
selected = currentScreen == DesktopScreen.Notifications,
onClick = { onScreenChange(DesktopScreen.Notifications) },
)
NavigationRailItem(
icon = { Icon(Icons.Default.Person, contentDescription = "Profile") },
label = { Text("Profile") },
selected = currentScreen == DesktopScreen.MyProfile || currentScreen is DesktopScreen.UserProfile,
onClick = { onScreenChange(DesktopScreen.MyProfile) },
)
Spacer(Modifier.weight(1f))
HorizontalDivider(Modifier.padding(horizontal = 16.dp))
NavigationRailItem(
icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") },
label = { Text("Settings") },
selected = currentScreen == DesktopScreen.Settings,
onClick = { onScreenChange(DesktopScreen.Settings) },
)
Spacer(Modifier.height(16.dp))
}
VerticalDivider()
// Main Content
Box(
modifier = Modifier.weight(1f).fillMaxHeight().padding(24.dp),
) {
when (currentScreen) {
DesktopScreen.Feed ->
FeedScreen(
relayManager = relayManager,
localCache = localCache,
account = account,
nwcConnection = nwcConnection,
onCompose = onShowComposeDialog,
onNavigateToProfile = { pubKeyHex ->
onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
},
onNavigateToThread = { noteId ->
onScreenChange(DesktopScreen.Thread(noteId))
},
onZapFeedback = onZapFeedback,
)
DesktopScreen.Reads ->
ReadsScreen(
relayManager = relayManager,
localCache = localCache,
account = account,
onNavigateToProfile = { pubKeyHex ->
onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
},
onNavigateToArticle = { noteId ->
onScreenChange(DesktopScreen.Thread(noteId))
},
)
DesktopScreen.Search ->
SearchScreen(
localCache = localCache,
relayManager = relayManager,
onNavigateToProfile = { pubKeyHex ->
onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
},
onNavigateToThread = { noteId ->
onScreenChange(DesktopScreen.Thread(noteId))
},
)
DesktopScreen.Bookmarks ->
BookmarksScreen(
relayManager = relayManager,
localCache = localCache,
account = account,
nwcConnection = nwcConnection,
onNavigateToProfile = { pubKeyHex ->
onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
},
onNavigateToThread = { noteId ->
onScreenChange(DesktopScreen.Thread(noteId))
},
onZapFeedback = onZapFeedback,
)
DesktopScreen.Messages -> MessagesPlaceholder()
DesktopScreen.Notifications -> NotificationsScreen(relayManager, account)
DesktopScreen.MyProfile ->
UserProfileScreen(
pubKeyHex = account.pubKeyHex,
relayManager = relayManager,
localCache = localCache,
account = account,
nwcConnection = nwcConnection,
onBack = { onScreenChange(DesktopScreen.Feed) },
onCompose = onShowComposeDialog,
onNavigateToProfile = { pubKeyHex ->
onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
},
onZapFeedback = onZapFeedback,
)
is DesktopScreen.UserProfile ->
UserProfileScreen(
pubKeyHex = currentScreen.pubKeyHex,
relayManager = relayManager,
localCache = localCache,
account = account,
nwcConnection = nwcConnection,
onBack = { onScreenChange(DesktopScreen.Feed) },
onCompose = onShowComposeDialog,
onNavigateToProfile = { pubKeyHex ->
onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
},
onZapFeedback = onZapFeedback,
)
is DesktopScreen.Thread ->
ThreadScreen(
noteId = currentScreen.noteId,
relayManager = relayManager,
localCache = localCache,
account = account,
nwcConnection = nwcConnection,
onBack = { onScreenChange(DesktopScreen.Feed) },
onNavigateToProfile = { pubKeyHex ->
onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
},
onNavigateToThread = { noteId ->
onScreenChange(DesktopScreen.Thread(noteId))
},
onZapFeedback = onZapFeedback,
onReply = onShowReplyDialog,
)
DesktopScreen.Settings -> RelaySettingsScreen(relayManager, account, accountManager)
}
}
}
// Snackbar for zap feedback
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp),
)
}
}
@@ -443,10 +574,19 @@ fun ProfileScreen(
fun RelaySettingsScreen(
relayManager: DesktopRelayConnectionManager,
account: AccountState.LoggedIn,
accountManager: AccountManager,
) {
val relayStatuses by relayManager.relayStatuses.collectAsState()
val connectedRelays by relayManager.connectedRelays.collectAsState()
val nwcConnection by accountManager.nwcConnection.collectAsState()
var newRelayUrl by remember { mutableStateOf("") }
var nwcInput by remember { mutableStateOf("") }
var nwcError by remember { mutableStateOf<String?>(null) }
// Load NWC on first composition
LaunchedEffect(Unit) {
accountManager.loadNwcConnection()
}
Column(modifier = Modifier.fillMaxSize()) {
Text(
@@ -457,6 +597,88 @@ fun RelaySettingsScreen(
Spacer(Modifier.height(24.dp))
// Wallet Connect Section
Text(
"Wallet Connect (NWC)",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
)
Spacer(Modifier.height(8.dp))
Text(
"Connect a Lightning wallet to enable zaps. Get a connection string from Alby, Mutiny, or other NWC-compatible wallets.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(12.dp))
if (nwcConnection != null) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text(
"Wallet Connected",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
)
Text(
"Relay: ${nwcConnection!!.relayUri.url}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
OutlinedButton(
onClick = { accountManager.clearNwcConnection() },
colors =
ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error,
),
) {
Text("Disconnect")
}
}
} else {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = nwcInput,
onValueChange = {
nwcInput = it
nwcError = null
},
label = { Text("NWC Connection String") },
placeholder = { Text("nostr+walletconnect://...") },
modifier = Modifier.weight(1f),
singleLine = true,
isError = nwcError != null,
supportingText = nwcError?.let { { Text(it, color = MaterialTheme.colorScheme.error) } },
)
Button(
onClick = {
val result = accountManager.setNwcConnection(nwcInput)
result.fold(
onSuccess = { nwcInput = "" },
onFailure = { nwcError = it.message ?: "Invalid connection string" },
)
},
enabled = nwcInput.isNotBlank(),
) {
Text("Connect")
}
}
}
Spacer(Modifier.height(24.dp))
HorizontalDivider()
Spacer(Modifier.height(24.dp))
// Developer Settings Section (only in debug mode)
if (DebugConfig.isDebugMode) {
com.vitorpamplona.amethyst.desktop.ui
@@ -0,0 +1,262 @@
/**
* 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.cache
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.ICacheEventStream
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.model.cache.IChannel
import com.vitorpamplona.amethyst.commons.services.nwc.NwcPaymentTracker
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
/**
* Desktop implementation of ICacheProvider.
*
* Provides in-memory caching of Users and Notes for the desktop application.
* Supports searching users by name prefix for the search functionality.
*/
class DesktopLocalCache : ICacheProvider {
private val users = ConcurrentHashMap<HexKey, User>()
private val notes = ConcurrentHashMap<HexKey, Note>()
private val deletedEvents = ConcurrentHashMap.newKeySet<HexKey>()
private val eventStream = DesktopCacheEventStream()
val paymentTracker = NwcPaymentTracker()
// ----- User operations -----
override fun getUserIfExists(pubkey: HexKey): User? = users[pubkey]
override fun getOrCreateUser(pubkey: HexKey): User =
users.getOrPut(pubkey) {
// Create placeholder notes for relay lists
val nip65Note = getOrCreateNote("nip65:$pubkey")
val dmNote = getOrCreateNote("dm:$pubkey")
User(pubkey, nip65Note, dmNote, this)
}
override fun countUsers(predicate: (String, Any) -> Boolean): Int = users.count { (key, user) -> predicate(key, user) }
override fun findUsersStartingWith(
prefix: String,
limit: Int,
): List<User> {
if (prefix.isBlank()) return emptyList()
// Check if it's a valid pubkey/npub first
val pubkeyHex = decodePublicKeyAsHexOrNull(prefix)
if (pubkeyHex != null) {
val user = getUserIfExists(pubkeyHex)
if (user != null) return listOf(user)
}
// Search by name/displayName/nip05/lud16
return users.values
.filter { user ->
user.anyNameStartsWith(prefix) ||
user.pubkeyHex.startsWith(prefix, ignoreCase = true) ||
user.pubkeyNpub().startsWith(prefix, ignoreCase = true)
}.sortedWith(
compareBy(
{ !it.toBestDisplayName().startsWith(prefix, ignoreCase = true) },
{ it.toBestDisplayName().lowercase() },
{ it.pubkeyHex },
),
).take(limit)
}
/**
* Updates user metadata from a MetadataEvent.
* Called when receiving kind 0 events from relays.
*/
fun consumeMetadata(event: MetadataEvent) {
val user = getOrCreateUser(event.pubKey)
// Only update if newer
val currentMetadata = user.latestMetadata
if (currentMetadata == null || event.createdAt > currentMetadata.createdAt) {
user.latestMetadata = event
user.info = event.contactMetaData()
}
}
// ----- NWC Payment operations -----
/**
* Consumes a NIP-47 payment request event.
* Registers the request with the tracker and links it to the zapped note.
*
* @param event The payment request event
* @param zappedNote The note being zapped (if this payment is for a zap)
* @param relay The relay this event came from
* @param onResponse Callback invoked when wallet responds
* @return true if event was processed, false if already seen
*/
fun consume(
event: LnZapPaymentRequestEvent,
zappedNote: Note?,
relay: NormalizedRelayUrl?,
onResponse: suspend (LnZapPaymentResponseEvent) -> Unit,
): Boolean {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
// Already processed this event
if (note.event != null) return false
note.loadEvent(event, author, emptyList())
relay?.let { note.addRelay(it) }
zappedNote?.addZapPayment(note, null)
paymentTracker.registerRequest(event.id, zappedNote, onResponse)
return true
}
/**
* Consumes a NIP-47 payment response event.
* Matches to pending request, links notes, and invokes callback.
*
* @param event The payment response event
* @param relay The relay this event came from
* @return true if event was processed, false if no matching request
*/
fun consume(
event: LnZapPaymentResponseEvent,
relay: NormalizedRelayUrl?,
): Boolean {
val requestId = event.requestId()
val pending = paymentTracker.onResponseReceived(requestId) ?: return false
val requestNote = requestId?.let { getNoteIfExists(it) }
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
// Already processed this event
if (note.event != null) return false
note.loadEvent(event, author, emptyList())
relay?.let { note.addRelay(it) }
// Link response to zapped note via request
requestNote?.let { req -> pending.zappedNote?.addZapPayment(req, note) }
// Invoke callback on IO dispatcher
GlobalScope.launch(Dispatchers.IO) {
pending.onResponse(event)
}
return true
}
// ----- Note operations -----
override fun getNoteIfExists(hexKey: HexKey): Note? = notes[hexKey]
override fun checkGetOrCreateNote(hexKey: HexKey): Note = getOrCreateNote(hexKey)
fun getOrCreateNote(hexKey: HexKey): Note =
notes.getOrPut(hexKey) {
Note(hexKey, this)
}
// ----- Channel operations -----
override fun getAnyChannel(note: Any?): IChannel? {
// Desktop doesn't support channels yet
return null
}
// ----- Deletion tracking -----
override fun hasBeenDeleted(event: Any): Boolean =
when (event) {
is Note -> deletedEvents.contains(event.idHex)
is com.vitorpamplona.quartz.nip01Core.core.Event -> deletedEvents.contains(event.id)
else -> false
}
fun markAsDeleted(eventId: HexKey) {
deletedEvents.add(eventId)
}
// ----- Event stream -----
override fun getEventStream(): ICacheEventStream = eventStream
/**
* Emits a new note bundle to observers.
*/
suspend fun emitNewNotes(notes: Set<Note>) {
eventStream.emitNewNotes(notes)
}
/**
* Emits deleted notes to observers.
*/
suspend fun emitDeletedNotes(notes: Set<Note>) {
eventStream.emitDeletedNotes(notes)
}
// ----- Stats -----
fun userCount(): Int = users.size
fun noteCount(): Int = notes.size
fun clear() {
users.clear()
notes.clear()
deletedEvents.clear()
}
}
/**
* Desktop implementation of ICacheEventStream.
*/
class DesktopCacheEventStream : ICacheEventStream {
private val _newEventBundles = MutableSharedFlow<Set<Note>>(replay = 0)
private val _deletedEventBundles = MutableSharedFlow<Set<Note>>(replay = 0)
override val newEventBundles: SharedFlow<Set<Note>> = _newEventBundles
override val deletedEventBundles: SharedFlow<Set<Note>> = _deletedEventBundles
suspend fun emitNewNotes(notes: Set<Note>) {
_newEventBundles.emit(notes)
}
suspend fun emitDeletedNotes(notes: Set<Note>) {
_deletedEventBundles.emit(notes)
}
}
@@ -0,0 +1,181 @@
/**
* 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.nwc
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.Response
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.coroutines.resume
/**
* Handles NIP-47 (Nostr Wallet Connect) payments for desktop.
*
* Flow:
* 1. Create payment request event with BOLT11 invoice
* 2. Register with tracker for persistent tracking in Note.zapPayments
* 3. Send to wallet's relay
* 4. Subscribe and wait for wallet response
*
* @param relayManager Manages relay connections for sending/subscribing
* @param localCache Cache for persistent payment tracking
*/
class NwcPaymentHandler(
private val relayManager: DesktopRelayConnectionManager,
private val localCache: DesktopLocalCache,
) {
sealed class PaymentResult {
data class Success(
val preimage: String?,
) : PaymentResult()
data class Error(
val message: String,
) : PaymentResult()
data object Timeout : PaymentResult()
}
/**
* Sends a payment request via NWC and waits for response.
* Payment is tracked in Note.zapPayments for the zapped note.
*
* @param bolt11 The BOLT11 invoice to pay
* @param nwcConnection The NWC connection details (pubkey, relay, secret)
* @param zappedNote The note being zapped (for tracking in Note.zapPayments)
* @param timeoutMs How long to wait for payment response (default 60s)
* @return PaymentResult indicating success, error, or timeout
*/
suspend fun payInvoice(
bolt11: String,
nwcConnection: Nip47WalletConnect.Nip47URINorm,
zappedNote: Note? = null,
timeoutMs: Long = 60_000,
): PaymentResult {
val secret = nwcConnection.secret ?: return PaymentResult.Error("NWC connection has no secret")
// Create signer from NWC secret
val nwcSigner = NostrSignerInternal(KeyPair(secret.hexToByteArray()))
// Create payment request event
val requestEvent =
LnZapPaymentRequestEvent.create(
lnInvoice = bolt11,
walletServicePubkey = nwcConnection.pubKeyHex,
signer = nwcSigner,
)
// Register request note in cache for tracking
val requestNote = localCache.getOrCreateNote(requestEvent.id)
requestNote.loadEvent(requestEvent, localCache.getOrCreateUser(requestEvent.pubKey), emptyList())
requestNote.addRelay(nwcConnection.relayUri)
// Link to zapped note for persistent tracking
zappedNote?.addZapPayment(requestNote, null)
// Send request to wallet's relay
relayManager.sendToRelay(nwcConnection.relayUri, requestEvent)
// Subscribe and wait for response with timeout
return withTimeoutOrNull(timeoutMs) {
waitForResponse(requestEvent.id, nwcConnection, nwcSigner, zappedNote, requestNote)
} ?: PaymentResult.Timeout
}
private suspend fun waitForResponse(
requestId: String,
nwcConnection: Nip47WalletConnect.Nip47URINorm,
nwcSigner: NostrSignerInternal,
zappedNote: Note?,
requestNote: Note,
): PaymentResult =
suspendCancellableCoroutine { continuation ->
val filter =
Filter(
kinds = listOf(LnZapPaymentResponseEvent.KIND),
authors = listOf(nwcConnection.pubKeyHex),
tags = mapOf("e" to listOf(requestId)),
)
val subId = "nwc-response-${requestId.take(8)}"
relayManager.subscribeOnRelay(
relay = nwcConnection.relayUri,
subId = subId,
filters = listOf(filter),
onEvent = { event, relay ->
if (event is LnZapPaymentResponseEvent && event.requestId() == requestId) {
// Unsubscribe
relayManager.closeSubscription(nwcConnection.relayUri, subId)
// Store response note and link to zapped note
val responseNote = localCache.getOrCreateNote(event.id)
responseNote.loadEvent(event, localCache.getOrCreateUser(event.pubKey), emptyList())
responseNote.addRelay(relay)
zappedNote?.addZapPayment(requestNote, responseNote)
// Decrypt and process response
try {
kotlinx.coroutines.runBlocking {
val response = event.decrypt(nwcSigner)
val result = processResponse(response)
if (continuation.isActive) {
continuation.resume(result)
}
}
} catch (e: Exception) {
if (continuation.isActive) {
continuation.resume(PaymentResult.Error("Failed to decrypt response: ${e.message}"))
}
}
}
},
)
continuation.invokeOnCancellation {
relayManager.closeSubscription(nwcConnection.relayUri, subId)
}
}
private fun processResponse(response: Response): PaymentResult =
when (response) {
is PayInvoiceSuccessResponse -> {
PaymentResult.Success(response.result?.preimage)
}
is PayInvoiceErrorResponse -> {
PaymentResult.Error(response.error?.message ?: "Unknown error")
}
else -> {
PaymentResult.Error("Unexpected response type: ${response.resultType}")
}
}
}
@@ -0,0 +1,336 @@
/**
* 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.clickable
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.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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.state.EventCollectionState
import com.vitorpamplona.amethyst.commons.subscriptions.FilterBuilders
import com.vitorpamplona.amethyst.commons.subscriptions.SubscriptionConfig
import com.vitorpamplona.amethyst.commons.subscriptions.rememberSubscription
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
import com.vitorpamplona.amethyst.commons.ui.note.NoteCard
import com.vitorpamplona.amethyst.commons.util.toNoteDisplayData
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark
import kotlinx.coroutines.launch
private enum class BookmarkTab { PUBLIC, PRIVATE }
/**
* Screen displaying user's bookmarked notes (public and private).
*/
@Composable
fun BookmarksScreen(
relayManager: DesktopRelayConnectionManager,
localCache: DesktopLocalCache,
account: AccountState.LoggedIn,
nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null,
onNavigateToProfile: (String) -> Unit = {},
onNavigateToThread: (String) -> Unit = {},
onZapFeedback: (ZapFeedback) -> Unit = {},
) {
val relayStatuses by relayManager.relayStatuses.collectAsState()
val scope = rememberCoroutineScope()
// Tab state
var selectedTab by remember { mutableStateOf(BookmarkTab.PUBLIC) }
// State for bookmark list
var bookmarkList by remember { mutableStateOf<BookmarkListEvent?>(null) }
var publicBookmarkIds by remember { mutableStateOf<List<String>>(emptyList()) }
var privateBookmarkIds by remember { mutableStateOf<List<String>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
var hasReceivedEose by remember { mutableStateOf(false) }
// State for fetched bookmark events
val publicEventState =
remember(account.pubKeyHex) {
EventCollectionState<Event>(
getId = { it.id },
maxSize = 100,
scope = scope,
)
}
val publicEvents by publicEventState.items.collectAsState()
val privateEventState =
remember(account.pubKeyHex) {
EventCollectionState<Event>(
getId = { it.id },
maxSize = 100,
scope = scope,
)
}
val privateEvents by privateEventState.items.collectAsState()
// Subscribe to user's bookmark list (kind 30001)
rememberSubscription(relayStatuses, account.pubKeyHex, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isNotEmpty()) {
SubscriptionConfig(
subId = "bookmarks-list-${account.pubKeyHex.take(8)}",
filters =
listOf(
FilterBuilders.byAuthors(
authors = listOf(account.pubKeyHex),
kinds = listOf(BookmarkListEvent.KIND),
limit = 1,
),
),
relays = configuredRelays,
onEvent = { event, _, _, _ ->
if (event is BookmarkListEvent) {
bookmarkList = event
// Extract public bookmarked event IDs
val pubIds =
event
.publicBookmarks()
.filterIsInstance<EventBookmark>()
.map { it.eventId }
publicBookmarkIds = pubIds
}
},
onEose = { _, _ ->
hasReceivedEose = true
isLoading = false
},
)
} else {
isLoading = false
null
}
}
// Decrypt private bookmarks when bookmark list changes
LaunchedEffect(bookmarkList) {
bookmarkList?.let { list ->
scope.launch {
try {
val privateBookmarks = list.privateBookmarks(account.signer)
val privIds =
privateBookmarks
?.filterIsInstance<EventBookmark>()
?.map { it.eventId }
?: emptyList()
privateBookmarkIds = privIds
} catch (e: Exception) {
println("Failed to decrypt private bookmarks: ${e.message}")
privateBookmarkIds = emptyList()
}
}
}
}
// Subscribe to fetch the actual public bookmarked events
rememberSubscription(relayStatuses, publicBookmarkIds, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isNotEmpty() && publicBookmarkIds.isNotEmpty()) {
publicEventState.clear()
SubscriptionConfig(
subId = "public-bookmarked-events-${System.currentTimeMillis()}",
filters =
listOf(
FilterBuilders.byIds(publicBookmarkIds),
),
relays = configuredRelays,
onEvent = { event, _, _, _ ->
publicEventState.addItem(event)
},
onEose = { _, _ -> },
)
} else {
null
}
}
// Subscribe to fetch the actual private bookmarked events
rememberSubscription(relayStatuses, privateBookmarkIds, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isNotEmpty() && privateBookmarkIds.isNotEmpty()) {
privateEventState.clear()
SubscriptionConfig(
subId = "private-bookmarked-events-${System.currentTimeMillis()}",
filters =
listOf(
FilterBuilders.byIds(privateBookmarkIds),
),
relays = configuredRelays,
onEvent = { event, _, _, _ ->
privateEventState.addItem(event)
},
onEose = { _, _ -> },
)
} else {
null
}
}
val currentEvents = if (selectedTab == BookmarkTab.PUBLIC) publicEvents else privateEvents
val currentBookmarkIds = if (selectedTab == BookmarkTab.PUBLIC) publicBookmarkIds else privateBookmarkIds
Column(modifier = Modifier.fillMaxSize()) {
// Header with tabs
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Bookmarks",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground,
)
Spacer(Modifier.weight(1f))
// Tab selector
Row(
horizontalArrangement =
androidx.compose.foundation.layout.Arrangement
.spacedBy(8.dp),
) {
FilterChip(
selected = selectedTab == BookmarkTab.PUBLIC,
onClick = { selectedTab = BookmarkTab.PUBLIC },
label = { Text("Public (${publicBookmarkIds.size})") },
)
FilterChip(
selected = selectedTab == BookmarkTab.PRIVATE,
onClick = { selectedTab = BookmarkTab.PRIVATE },
label = { Text("Private (${privateBookmarkIds.size})") },
)
}
}
// Content
when {
isLoading && !hasReceivedEose -> {
LoadingState(message = "Loading bookmarks...")
}
currentBookmarkIds.isEmpty() && hasReceivedEose -> {
EmptyState(
title = if (selectedTab == BookmarkTab.PUBLIC) "No public bookmarks" else "No private bookmarks",
description =
if (selectedTab == BookmarkTab.PUBLIC) {
"Bookmark notes publicly to save them here"
} else {
"Private bookmarks are encrypted and only visible to you"
},
)
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
) {
items(currentEvents, key = { it.id }) { event ->
Column(
modifier =
Modifier.clickable {
onNavigateToThread(event.id)
},
) {
NoteCard(
note = event.toNoteDisplayData(localCache),
onAuthorClick = onNavigateToProfile,
)
NoteActionsRow(
event = event,
relayManager = relayManager,
localCache = localCache,
account = account,
nwcConnection = nwcConnection,
onReplyClick = { onNavigateToThread(event.id) },
onZapFeedback = onZapFeedback,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
isBookmarked = true,
bookmarkList = bookmarkList,
onBookmarkChanged = { newList ->
bookmarkList = newList
// Update public bookmark IDs
val pubIds =
newList
.publicBookmarks()
.filterIsInstance<EventBookmark>()
.map { it.eventId }
publicBookmarkIds = pubIds
// Decrypt and update private bookmark IDs
scope.launch {
try {
val privateBookmarks = newList.privateBookmarks(account.signer)
val privIds =
privateBookmarks
?.filterIsInstance<EventBookmark>()
?.map { it.eventId }
?: emptyList()
privateBookmarkIds = privIds
} catch (e: Exception) {
// Keep existing private IDs if decryption fails
}
}
// Remove unbookmarked event from appropriate list
if (!pubIds.contains(event.id)) {
publicEventState.removeItem(event.id)
}
if (!privateBookmarkIds.contains(event.id)) {
privateEventState.removeItem(event.id)
}
},
)
}
HorizontalDivider(thickness = 1.dp)
}
}
}
}
}
}
@@ -55,17 +55,30 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.account.AccountState
import com.vitorpamplona.amethyst.commons.state.EventCollectionState
import com.vitorpamplona.amethyst.commons.subscriptions.FeedMode
import com.vitorpamplona.amethyst.commons.subscriptions.FilterBuilders
import com.vitorpamplona.amethyst.commons.subscriptions.createBatchMetadataSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.createContactListSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.createFollowingFeedSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.createGlobalFeedSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.createReactionsSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.createRepliesSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.createRepostsSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.createZapsSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.rememberSubscription
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
import com.vitorpamplona.amethyst.commons.ui.note.NoteCard
import com.vitorpamplona.amethyst.commons.util.toNoteDisplayData
import com.vitorpamplona.amethyst.desktop.DesktopPreferences
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
/**
* Note card with action buttons.
@@ -74,11 +87,23 @@ import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
fun FeedNoteCard(
event: Event,
relayManager: DesktopRelayConnectionManager,
localCache: DesktopLocalCache,
account: AccountState.LoggedIn?,
nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null,
onReply: () -> Unit,
onZapFeedback: (ZapFeedback) -> Unit,
onNavigateToProfile: (String) -> Unit = {},
onNavigateToThread: (String) -> Unit = {},
zapReceipts: List<ZapReceipt> = emptyList(),
reactionCount: Int = 0,
replyCount: Int = 0,
repostCount: Int = 0,
bookmarkList: BookmarkListEvent? = null,
isBookmarked: Boolean = false,
onBookmarkChanged: (BookmarkListEvent) -> Unit = {},
) {
val zapAmountSats = zapReceipts.sumOf { it.amountSats }
Column(
modifier =
Modifier.clickable {
@@ -86,7 +111,7 @@ fun FeedNoteCard(
},
) {
NoteCard(
note = event.toNoteDisplayData(),
note = event.toNoteDisplayData(localCache),
onAuthorClick = onNavigateToProfile,
)
@@ -95,9 +120,21 @@ fun FeedNoteCard(
NoteActionsRow(
event = event,
relayManager = relayManager,
localCache = localCache,
account = account,
nwcConnection = nwcConnection,
onReplyClick = onReply,
onZapFeedback = onZapFeedback,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
zapCount = zapReceipts.size,
zapAmountSats = zapAmountSats,
zapReceipts = zapReceipts,
reactionCount = reactionCount,
replyCount = replyCount,
repostCount = repostCount,
bookmarkList = bookmarkList,
isBookmarked = isBookmarked,
onBookmarkChanged = onBookmarkChanged,
)
}
}
@@ -106,10 +143,13 @@ fun FeedNoteCard(
@Composable
fun FeedScreen(
relayManager: DesktopRelayConnectionManager,
localCache: DesktopLocalCache,
account: AccountState.LoggedIn? = null,
nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null,
onCompose: () -> Unit = {},
onNavigateToProfile: (String) -> Unit = {},
onNavigateToThread: (String) -> Unit = {},
onZapFeedback: (ZapFeedback) -> Unit = {},
) {
val connectedRelays by relayManager.connectedRelays.collectAsState()
val relayStatuses by relayManager.relayStatuses.collectAsState()
@@ -125,8 +165,14 @@ fun FeedScreen(
}
val events by eventState.items.collectAsState()
var replyToEvent by remember { mutableStateOf<Event?>(null) }
var feedMode by remember { mutableStateOf(FeedMode.GLOBAL) }
var feedMode by remember { mutableStateOf(DesktopPreferences.feedMode) }
var followedUsers by remember { mutableStateOf<Set<String>>(emptySet()) }
var zapsByEvent by remember { mutableStateOf<Map<String, List<ZapReceipt>>>(emptyMap()) }
var reactionsByEvent by remember { mutableStateOf<Map<String, Int>>(emptyMap()) }
var repliesByEvent by remember { mutableStateOf<Map<String, Int>>(emptyMap()) }
var repostsByEvent by remember { mutableStateOf<Map<String, Int>>(emptyMap()) }
var bookmarkList by remember { mutableStateOf<BookmarkListEvent?>(null) }
var bookmarkedEventIds by remember { mutableStateOf<Set<String>>(emptySet()) }
// Track EOSE to know when initial load is complete
var eoseReceivedCount by remember { mutableStateOf(0) }
@@ -150,6 +196,41 @@ fun FeedScreen(
}
}
// Load user's bookmark list
rememberSubscription(relayStatuses, account, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isNotEmpty() && account != null) {
com.vitorpamplona.amethyst.commons.subscriptions.SubscriptionConfig(
subId = "bookmarks-${account.pubKeyHex.take(8)}",
filters =
listOf(
FilterBuilders.byAuthors(
authors = listOf(account.pubKeyHex),
kinds = listOf(BookmarkListEvent.KIND),
limit = 1,
),
),
relays = configuredRelays,
onEvent = { event, _, _, _ ->
if (event is BookmarkListEvent) {
bookmarkList = event
// Extract public bookmarked event IDs
val pubIds =
event
.publicBookmarks()
.filterIsInstance<com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark>()
.map { it.eventId }
.toSet()
bookmarkedEventIds = pubIds
}
},
onEose = { _, _ -> },
)
} else {
null
}
}
// Clear events and reset EOSE when feed mode changes
remember(feedMode) {
eventState.clear()
@@ -168,6 +249,10 @@ fun FeedScreen(
createGlobalFeedSubscription(
relays = configuredRelays,
onEvent = { event, _, _, _ ->
// Store metadata events in cache
if (event is MetadataEvent) {
localCache.consumeMetadata(event)
}
eventState.addItem(event)
},
onEose = { _, _ ->
@@ -181,6 +266,10 @@ fun FeedScreen(
relays = configuredRelays,
followedUsers = followedUsers.toList(),
onEvent = { event, _, _, _ ->
// Store metadata events in cache
if (event is MetadataEvent) {
localCache.consumeMetadata(event)
}
eventState.addItem(event)
},
onEose = { _, _ ->
@@ -194,6 +283,164 @@ fun FeedScreen(
}
}
// Subscribe to zaps for visible events
val eventIds = events.map { it.id }
rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty() || eventIds.isEmpty()) {
return@rememberSubscription null
}
createZapsSubscription(
relays = configuredRelays,
eventIds = eventIds,
onEvent = { event, _, _, _ ->
if (event is LnZapEvent) {
val receipt = event.toZapReceipt(localCache) ?: return@createZapsSubscription
val targetEventId = event.zappedPost().firstOrNull() ?: return@createZapsSubscription
zapsByEvent =
zapsByEvent.toMutableMap().apply {
val existing = this[targetEventId] ?: emptyList()
if (existing.none { it.createdAt == receipt.createdAt && it.senderPubKey == receipt.senderPubKey }) {
this[targetEventId] = existing + receipt
}
}
}
},
)
}
// Subscribe to metadata for zap senders (to show display names)
val zapSenderPubkeys =
zapsByEvent.values
.flatten()
.map { it.senderPubKey }
.distinct()
rememberSubscription(relayStatuses, zapSenderPubkeys, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty() || zapSenderPubkeys.isEmpty()) {
return@rememberSubscription null
}
// Only fetch metadata for users we don't have yet
val missingPubkeys =
zapSenderPubkeys.filter { pubkey ->
localCache.getUserIfExists(pubkey)?.info == null
}
if (missingPubkeys.isEmpty()) {
return@rememberSubscription null
}
createBatchMetadataSubscription(
relays = configuredRelays,
pubKeyHexList = missingPubkeys,
onEvent = { event, _, _, _ ->
if (event is MetadataEvent) {
localCache.consumeMetadata(event)
}
},
)
}
// Subscribe to reactions for visible events
rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty() || eventIds.isEmpty()) {
return@rememberSubscription null
}
createReactionsSubscription(
relays = configuredRelays,
eventIds = eventIds,
onEvent = { event, _, _, _ ->
if (event is ReactionEvent) {
val targetEventId = event.originalPost().firstOrNull() ?: return@createReactionsSubscription
reactionsByEvent =
reactionsByEvent.toMutableMap().apply {
this[targetEventId] = (this[targetEventId] ?: 0) + 1
}
}
},
)
}
// Subscribe to replies for visible events
rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty() || eventIds.isEmpty()) {
return@rememberSubscription null
}
createRepliesSubscription(
relays = configuredRelays,
eventIds = eventIds,
onEvent = { event, _, _, _ ->
// Find the event this is replying to
val replyToId =
event.tags
.filter { it.size >= 2 && it[0] == "e" }
.lastOrNull()
?.get(1) ?: return@createRepliesSubscription
if (replyToId in eventIds) {
repliesByEvent =
repliesByEvent.toMutableMap().apply {
this[replyToId] = (this[replyToId] ?: 0) + 1
}
}
},
)
}
// Subscribe to reposts for visible events
rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty() || eventIds.isEmpty()) {
return@rememberSubscription null
}
createRepostsSubscription(
relays = configuredRelays,
eventIds = eventIds,
onEvent = { event, _, _, _ ->
if (event is RepostEvent) {
val targetEventId = event.boostedEventId() ?: return@createRepostsSubscription
repostsByEvent =
repostsByEvent.toMutableMap().apply {
this[targetEventId] = (this[targetEventId] ?: 0) + 1
}
}
},
)
}
// Subscribe to metadata for note authors (to enable zaps and populate search cache)
val authorPubkeys = events.map { it.pubKey }.distinct()
rememberSubscription(relayStatuses, authorPubkeys, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty() || authorPubkeys.isEmpty()) {
return@rememberSubscription null
}
// Only fetch metadata for users we don't have yet
val missingPubkeys =
authorPubkeys.filter { pubkey ->
localCache.getUserIfExists(pubkey)?.info == null
}
if (missingPubkeys.isEmpty()) {
return@rememberSubscription null
}
createBatchMetadataSubscription(
relays = configuredRelays,
pubKeyHexList = missingPubkeys,
onEvent = { event, _, _, _ ->
if (event is MetadataEvent) {
localCache.consumeMetadata(event)
}
},
)
}
Column(modifier = Modifier.fillMaxSize()) {
// Header with compose button
Row(
@@ -217,12 +464,18 @@ fun FeedScreen(
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
FilterChip(
selected = feedMode == FeedMode.GLOBAL,
onClick = { feedMode = FeedMode.GLOBAL },
onClick = {
feedMode = FeedMode.GLOBAL
DesktopPreferences.feedMode = FeedMode.GLOBAL
},
label = { Text("Global") },
)
FilterChip(
selected = feedMode == FeedMode.FOLLOWING,
onClick = { feedMode = FeedMode.FOLLOWING },
onClick = {
feedMode = FeedMode.FOLLOWING
DesktopPreferences.feedMode = FeedMode.FOLLOWING
},
label = { Text("Following") },
)
}
@@ -301,10 +554,29 @@ fun FeedScreen(
FeedNoteCard(
event = event,
relayManager = relayManager,
localCache = localCache,
account = account,
nwcConnection = nwcConnection,
onReply = { replyToEvent = event },
onZapFeedback = onZapFeedback,
onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
zapReceipts = zapsByEvent[event.id] ?: emptyList(),
reactionCount = reactionsByEvent[event.id] ?: 0,
replyCount = repliesByEvent[event.id] ?: 0,
repostCount = repostsByEvent[event.id] ?: 0,
bookmarkList = bookmarkList,
isBookmarked = bookmarkedEventIds.contains(event.id),
onBookmarkChanged = { newList ->
bookmarkList = newList
val pubIds =
newList
.publicBookmarks()
.filterIsInstance<com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark>()
.map { it.eventId }
.toSet()
bookmarkedEventIds = pubIds
},
)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,364 @@
/**
* 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.clickable
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.Refresh
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
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.collectAsState
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.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.account.AccountState
import com.vitorpamplona.amethyst.commons.state.EventCollectionState
import com.vitorpamplona.amethyst.commons.subscriptions.FeedMode
import com.vitorpamplona.amethyst.commons.subscriptions.createContactListSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.createFollowingLongFormFeedSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.createLongFormFeedSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.rememberSubscription
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private val dateFormat = SimpleDateFormat("MMM d, yyyy", Locale.getDefault())
private fun formatDate(timestamp: Long): String = dateFormat.format(Date(timestamp * 1000))
/**
* Card displaying long-form content (NIP-23) with title, summary, and image.
*/
@Composable
fun LongFormCard(
event: LongTextNoteEvent,
localCache: DesktopLocalCache,
onAuthorClick: (String) -> Unit = {},
onClick: () -> Unit = {},
) {
val author = localCache.getUserIfExists(event.pubKey)
val authorName = author?.info?.bestName() ?: event.pubKey.take(8)
val publishedAt = event.publishedAt() ?: event.createdAt
Card(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
) {
Column(modifier = Modifier.padding(16.dp)) {
// Title
event.title()?.let { title ->
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(8.dp))
}
// Summary
event.summary()?.let { summary ->
Text(
text = summary,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(12.dp))
}
// Footer with author and date
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = authorName,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable { onAuthorClick(event.pubKey) },
)
Text(
text = formatDate(publishedAt),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Topics/hashtags
val topics = event.topics()
if (topics.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
topics.take(3).forEach { topic ->
Text(
text = "#$topic",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.tertiary,
)
}
}
}
}
}
}
@Composable
fun ReadsScreen(
relayManager: DesktopRelayConnectionManager,
localCache: DesktopLocalCache,
account: AccountState.LoggedIn? = null,
onNavigateToProfile: (String) -> Unit = {},
onNavigateToArticle: (String) -> Unit = {},
) {
val connectedRelays by relayManager.connectedRelays.collectAsState()
val relayStatuses by relayManager.relayStatuses.collectAsState()
val scope = rememberCoroutineScope()
val eventState =
remember {
EventCollectionState<LongTextNoteEvent>(
getId = { it.id },
sortComparator = compareByDescending { it.publishedAt() ?: it.createdAt },
maxSize = 100,
scope = scope,
)
}
val events by eventState.items.collectAsState()
var feedMode by remember { mutableStateOf(FeedMode.GLOBAL) }
var followedUsers by remember { mutableStateOf<Set<String>>(emptySet()) }
var eoseReceivedCount by remember { mutableStateOf(0) }
val initialLoadComplete = eoseReceivedCount > 0
// Load followed users for Following feed mode
rememberSubscription(relayStatuses, account, feedMode, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isNotEmpty() && account != null && feedMode == FeedMode.FOLLOWING) {
createContactListSubscription(
relays = configuredRelays,
pubKeyHex = account.pubKeyHex,
onEvent = { event, _, _, _ ->
if (event is ContactListEvent) {
followedUsers = event.verifiedFollowKeySet()
}
},
)
} else {
null
}
}
// Clear events when feed mode changes
remember(feedMode) {
eventState.clear()
eoseReceivedCount = 0
}
// Subscribe to long-form content feed
rememberSubscription(relayStatuses, feedMode, followedUsers, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty()) {
return@rememberSubscription null
}
when (feedMode) {
FeedMode.GLOBAL -> {
createLongFormFeedSubscription(
relays = configuredRelays,
onEvent = { event, _, _, _ ->
if (event is LongTextNoteEvent) {
eventState.addItem(event)
}
},
onEose = { _, _ ->
eoseReceivedCount++
},
)
}
FeedMode.FOLLOWING -> {
if (followedUsers.isNotEmpty()) {
createFollowingLongFormFeedSubscription(
relays = configuredRelays,
followedUsers = followedUsers.toList(),
onEvent = { event, _, _, _ ->
if (event is LongTextNoteEvent) {
eventState.addItem(event)
}
},
onEose = { _, _ ->
eoseReceivedCount++
},
)
} else {
null
}
}
}
}
Column(modifier = Modifier.fillMaxSize()) {
// Header
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(
"Reads",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground,
)
// 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,
)
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),
)
}
}
}
}
Spacer(Modifier.height(8.dp))
when {
connectedRelays.isEmpty() -> {
LoadingState("Connecting to relays...")
}
feedMode == FeedMode.FOLLOWING && followedUsers.isEmpty() -> {
LoadingState("Loading followed users...")
}
events.isEmpty() && !initialLoadComplete -> {
LoadingState("Loading articles...")
}
events.isEmpty() && initialLoadComplete -> {
EmptyState(
title =
if (feedMode == FeedMode.FOLLOWING) {
"No articles from followed users"
} else {
"No articles found"
},
description =
if (feedMode == FeedMode.FOLLOWING) {
"Long-form articles from people you follow will appear here"
} else {
"Long-form articles from the network will appear here"
},
onRefresh = { relayManager.connect() },
)
}
else -> {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(events, key = { it.id }) { event ->
LongFormCard(
event = event,
localCache = localCache,
onAuthorClick = onNavigateToProfile,
onClick = { onNavigateToArticle(event.id) },
)
}
}
}
}
}
}
@@ -0,0 +1,451 @@
/**
* 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.clickable
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.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Tag
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.search.SearchResult
import com.vitorpamplona.amethyst.commons.subscriptions.createMetadataSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.createSearchPeopleSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.rememberSubscription
import com.vitorpamplona.amethyst.commons.ui.components.UserSearchCard
import com.vitorpamplona.amethyst.commons.viewmodels.SearchBarState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
@Composable
fun SearchScreen(
localCache: DesktopLocalCache,
relayManager: DesktopRelayConnectionManager,
onNavigateToProfile: (String) -> Unit,
onNavigateToThread: (String) -> Unit,
onNavigateToHashtag: (String) -> Unit = {},
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
val searchState = remember { SearchBarState(localCache, scope) }
val focusRequester = remember { FocusRequester() }
val relayStatuses by relayManager.relayStatuses.collectAsState()
// Collect state from SearchBarState
val searchText by searchState.searchText.collectAsState()
val bech32Results by searchState.bech32Results.collectAsState()
val cachedUserResults by searchState.cachedUserResults.collectAsState()
val relaySearchResults by searchState.relaySearchResults.collectAsState()
val isSearchingRelays by searchState.isSearchingRelays.collectAsState()
// NIP-50 relay search when local cache has few/no results
rememberSubscription(relayStatuses, searchText, cachedUserResults.size, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty()) return@rememberSubscription null
// Only search relays if we have a real query and limited local results
if (searchState.shouldSearchRelays) {
searchState.startRelaySearch()
createSearchPeopleSubscription(
relays = configuredRelays,
searchQuery = searchText,
limit = 20,
onEvent = { event, _, _, _ ->
if (event is MetadataEvent) {
localCache.consumeMetadata(event)
val user = localCache.getUserIfExists(event.pubKey)
if (user != null) {
searchState.addRelaySearchResult(user)
}
}
},
onEose = { _, _ ->
searchState.endRelaySearch()
},
)
} else {
null
}
}
// Subscribe to metadata for searched users (to populate cache)
rememberSubscription(relayStatuses, searchText, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty() || searchText.length < 2) {
return@rememberSubscription null
}
// If it's a specific pubkey search, fetch that user's metadata
val pubkeyHex = decodePublicKeyAsHexOrNull(searchText)
if (pubkeyHex != null) {
createMetadataSubscription(
relays = configuredRelays,
pubKeyHex = pubkeyHex,
onEvent = { event, _, _, _ ->
if (event is MetadataEvent) {
localCache.consumeMetadata(event)
}
},
)
} else {
null
}
}
// Auto-focus the search field
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
Column(
modifier = modifier.fillMaxSize(),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"Search",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground,
)
Text(
"${localCache.userCount()} users cached",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(16.dp))
// Search input field
OutlinedTextField(
value = searchText,
onValueChange = { searchState.updateSearchText(it) },
modifier =
Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
placeholder = { Text("Search by name, npub, nevent, or #hashtag") },
leadingIcon = {
Icon(
Icons.Default.Search,
contentDescription = "Search",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
trailingIcon = {
if (searchText.isNotEmpty()) {
IconButton(onClick = { searchState.clearSearch() }) {
Icon(
Icons.Default.Clear,
contentDescription = "Clear",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
singleLine = true,
shape = RoundedCornerShape(12.dp),
)
Spacer(Modifier.height(16.dp))
// Results
val hasResults = bech32Results.isNotEmpty() || cachedUserResults.isNotEmpty() || relaySearchResults.isNotEmpty()
if (!hasResults && searchText.isNotEmpty() && searchText.length >= 2 && !isSearchingRelays) {
Text(
"No matches found. Try a name, npub, nevent, or #hashtag.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
)
} else if (isSearchingRelays && !hasResults) {
Text(
"Searching relays...",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
)
} else if (hasResults) {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
// Bech32/hex results first
if (bech32Results.isNotEmpty()) {
item {
Text(
"Direct lookup",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 4.dp),
)
}
items(bech32Results) { result ->
SearchResultCard(
result = result,
onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
onNavigateToHashtag = onNavigateToHashtag,
)
}
}
// Cached user results
if (cachedUserResults.isNotEmpty()) {
if (bech32Results.isNotEmpty()) {
item {
HorizontalDivider(Modifier.padding(vertical = 8.dp))
}
}
item {
Text(
"Cached users (${cachedUserResults.size})",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 4.dp),
)
}
items(cachedUserResults, key = { "cached-${it.pubkeyHex}" }) { user ->
UserSearchCard(
user = user,
onClick = { onNavigateToProfile(user.pubkeyHex) },
)
}
}
// Relay search results (NIP-50)
if (relaySearchResults.isNotEmpty()) {
if (bech32Results.isNotEmpty() || cachedUserResults.isNotEmpty()) {
item {
HorizontalDivider(Modifier.padding(vertical = 8.dp))
}
}
item {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
"From relays (${relaySearchResults.size})",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 4.dp),
)
if (isSearchingRelays) {
Text(
"searching...",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
items(relaySearchResults, key = { "relay-${it.pubkeyHex}" }) { user ->
UserSearchCard(
user = user,
onClick = { onNavigateToProfile(user.pubkeyHex) },
)
}
} else if (isSearchingRelays && cachedUserResults.isEmpty()) {
item {
Text(
"Searching relays...",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 8.dp),
)
}
}
}
} else {
// Empty state
Column(
modifier = Modifier.fillMaxWidth().padding(top = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
"Search for users or notes",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
Text(
"Enter a name or Nostr identifier:",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
)
Spacer(Modifier.height(16.dp))
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
SearchHint("vitor", "Search by name")
SearchHint("npub1...", "User profile")
SearchHint("note1...", "Single note")
SearchHint("nevent1...", "Note with metadata")
SearchHint("#hashtag", "Hashtag search")
}
}
}
}
}
@Composable
private fun SearchHint(
identifier: String,
description: String,
) {
Row(
modifier = Modifier.fillMaxWidth(0.6f),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
identifier,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.width(16.dp))
Text(
description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun SearchResultCard(
result: SearchResult,
onNavigateToProfile: (String) -> Unit,
onNavigateToThread: (String) -> Unit,
onNavigateToHashtag: (String) -> Unit,
) {
Card(
modifier =
Modifier
.fillMaxWidth()
.clickable {
when (result) {
is SearchResult.UserResult -> onNavigateToProfile(result.pubKeyHex)
is SearchResult.CachedUserResult -> onNavigateToProfile(result.user.pubkeyHex)
is SearchResult.NoteResult -> onNavigateToThread(result.noteIdHex)
is SearchResult.AddressResult -> {
onNavigateToThread("${result.kind}:${result.pubKeyHex}:${result.dTag}")
}
is SearchResult.HashtagResult -> onNavigateToHashtag(result.hashtag)
}
},
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Row(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
imageVector =
when (result) {
is SearchResult.UserResult -> Icons.Default.Person
is SearchResult.CachedUserResult -> Icons.Default.Person
is SearchResult.NoteResult -> Icons.Default.Description
is SearchResult.AddressResult -> Icons.Default.Description
is SearchResult.HashtagResult -> Icons.Default.Tag
},
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary,
)
Column(modifier = Modifier.weight(1f)) {
Text(
when (result) {
is SearchResult.UserResult -> "User Profile"
is SearchResult.CachedUserResult -> result.user.toBestDisplayName()
is SearchResult.NoteResult -> "Note"
is SearchResult.AddressResult -> "Event (kind ${result.kind})"
is SearchResult.HashtagResult -> "#${result.hashtag}"
},
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
when (result) {
is SearchResult.UserResult -> result.displayId
is SearchResult.CachedUserResult -> result.user.pubkeyDisplayHex()
is SearchResult.NoteResult -> result.displayId
is SearchResult.AddressResult -> result.displayId
is SearchResult.HashtagResult -> "Search posts with this hashtag"
},
style = MaterialTheme.typography.bodySmall,
fontFamily = if (result is SearchResult.HashtagResult) null else FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Icon(
Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = "Navigate",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -52,16 +52,28 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.account.AccountState
import com.vitorpamplona.amethyst.commons.state.EventCollectionState
import com.vitorpamplona.amethyst.commons.subscriptions.FilterBuilders
import com.vitorpamplona.amethyst.commons.subscriptions.SubscriptionConfig
import com.vitorpamplona.amethyst.commons.subscriptions.createNoteSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.createReactionsSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.createRepliesSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.createRepostsSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.createThreadRepliesSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.createZapsSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.rememberSubscription
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
import com.vitorpamplona.amethyst.commons.ui.note.NoteCard
import com.vitorpamplona.amethyst.commons.ui.thread.drawReplyLevel
import com.vitorpamplona.amethyst.commons.util.toNoteDisplayData
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
/**
* Desktop Thread Screen - displays a note and all its replies in a thread view.
@@ -72,10 +84,14 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
fun ThreadScreen(
noteId: String,
relayManager: DesktopRelayConnectionManager,
localCache: DesktopLocalCache,
account: AccountState.LoggedIn?,
nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null,
onBack: () -> Unit,
onNavigateToProfile: (String) -> Unit = {},
onNavigateToThread: (String) -> Unit = {},
onZapFeedback: (ZapFeedback) -> Unit = {},
onReply: (Event) -> Unit = {},
) {
val connectedRelays by relayManager.connectedRelays.collectAsState()
val relayStatuses by relayManager.relayStatuses.collectAsState()
@@ -103,6 +119,50 @@ fun ThreadScreen(
var rootNoteEoseReceived by remember(noteId) { mutableStateOf(false) }
var repliesEoseReceived by remember(noteId) { mutableStateOf(false) }
// Track zaps per event
var zapsByEvent by remember(noteId) { mutableStateOf<Map<String, List<ZapReceipt>>>(emptyMap()) }
var reactionsByEvent by remember(noteId) { mutableStateOf<Map<String, Int>>(emptyMap()) }
var repliesByEvent by remember(noteId) { mutableStateOf<Map<String, Int>>(emptyMap()) }
var repostsByEvent by remember(noteId) { mutableStateOf<Map<String, Int>>(emptyMap()) }
// Bookmark state
var bookmarkList by remember { mutableStateOf<BookmarkListEvent?>(null) }
var bookmarkedEventIds by remember { mutableStateOf<Set<String>>(emptySet()) }
// Subscribe to user's bookmark list
rememberSubscription(relayStatuses, account, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isNotEmpty() && account != null) {
SubscriptionConfig(
subId = "thread-bookmarks-${account.pubKeyHex.take(8)}",
filters =
listOf(
FilterBuilders.byAuthors(
authors = listOf(account.pubKeyHex),
kinds = listOf(BookmarkListEvent.KIND),
limit = 1,
),
),
relays = configuredRelays,
onEvent = { event, _, _, _ ->
if (event is BookmarkListEvent) {
bookmarkList = event
val pubIds =
event
.publicBookmarks()
.filterIsInstance<EventBookmark>()
.map { it.eventId }
.toSet()
bookmarkedEventIds = pubIds
}
},
onEose = { _, _ -> },
)
} else {
null
}
}
// Subscribe to the root note
rememberSubscription(relayStatuses, noteId, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
@@ -144,6 +204,103 @@ fun ThreadScreen(
}
}
// Subscribe to zaps for thread events
val allEventIds = listOf(noteId) + replyEvents.map { it.id }
rememberSubscription(relayStatuses, allEventIds, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty() || allEventIds.isEmpty()) {
return@rememberSubscription null
}
createZapsSubscription(
relays = configuredRelays,
eventIds = allEventIds,
onEvent = { event, _, _, _ ->
if (event is LnZapEvent) {
val receipt = event.toZapReceipt(localCache) ?: return@createZapsSubscription
val targetEventId = event.zappedPost().firstOrNull() ?: return@createZapsSubscription
zapsByEvent =
zapsByEvent.toMutableMap().apply {
val existing = this[targetEventId] ?: emptyList()
if (existing.none { it.createdAt == receipt.createdAt && it.senderPubKey == receipt.senderPubKey }) {
this[targetEventId] = existing + receipt
}
}
}
},
)
}
// Subscribe to reactions for thread events
rememberSubscription(relayStatuses, allEventIds, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty() || allEventIds.isEmpty()) {
return@rememberSubscription null
}
createReactionsSubscription(
relays = configuredRelays,
eventIds = allEventIds,
onEvent = { event, _, _, _ ->
if (event is ReactionEvent) {
val targetEventId = event.originalPost().firstOrNull() ?: return@createReactionsSubscription
reactionsByEvent =
reactionsByEvent.toMutableMap().apply {
this[targetEventId] = (this[targetEventId] ?: 0) + 1
}
}
},
)
}
// Subscribe to replies for thread events (for counts)
rememberSubscription(relayStatuses, allEventIds, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty() || allEventIds.isEmpty()) {
return@rememberSubscription null
}
createRepliesSubscription(
relays = configuredRelays,
eventIds = allEventIds,
onEvent = { event, _, _, _ ->
val replyToId =
event.tags
.filter { it.size >= 2 && it[0] == "e" }
.lastOrNull()
?.get(1) ?: return@createRepliesSubscription
if (replyToId in allEventIds) {
repliesByEvent =
repliesByEvent.toMutableMap().apply {
this[replyToId] = (this[replyToId] ?: 0) + 1
}
}
},
)
}
// Subscribe to reposts for thread events
rememberSubscription(relayStatuses, allEventIds, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty() || allEventIds.isEmpty()) {
return@rememberSubscription null
}
createRepostsSubscription(
relays = configuredRelays,
eventIds = allEventIds,
onEvent = { event, _, _, _ ->
if (event is RepostEvent) {
val targetEventId = event.boostedEventId() ?: return@createRepostsSubscription
repostsByEvent =
repostsByEvent.toMutableMap().apply {
this[targetEventId] = (this[targetEventId] ?: 0) + 1
}
}
},
)
}
// Calculate reply level for an event based on e-tags
fun calculateLevel(event: Event): Int {
levelCache[event.id]?.let { return it }
@@ -205,16 +362,38 @@ fun ThreadScreen(
},
) {
NoteCard(
note = rootNote!!.toNoteDisplayData(),
note = rootNote!!.toNoteDisplayData(localCache),
onAuthorClick = onNavigateToProfile,
)
if (account != null) {
val rootZaps = zapsByEvent[noteId] ?: emptyList()
NoteActionsRow(
event = rootNote!!,
relayManager = relayManager,
localCache = localCache,
account = account,
onReplyClick = { /* TODO: Open reply dialog */ },
nwcConnection = nwcConnection,
onReplyClick = { onReply(rootNote!!) },
onZapFeedback = onZapFeedback,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
zapCount = rootZaps.size,
zapAmountSats = rootZaps.sumOf { it.amountSats },
zapReceipts = rootZaps,
reactionCount = reactionsByEvent[noteId] ?: 0,
replyCount = repliesByEvent[noteId] ?: 0,
repostCount = repostsByEvent[noteId] ?: 0,
bookmarkList = bookmarkList,
isBookmarked = bookmarkedEventIds.contains(noteId),
onBookmarkChanged = { newList ->
bookmarkList = newList
val pubIds =
newList
.publicBookmarks()
.filterIsInstance<EventBookmark>()
.map { it.eventId }
.toSet()
bookmarkedEventIds = pubIds
},
)
}
}
@@ -242,16 +421,38 @@ fun ThreadScreen(
},
) {
NoteCard(
note = event.toNoteDisplayData(),
note = event.toNoteDisplayData(localCache),
onAuthorClick = onNavigateToProfile,
)
if (account != null) {
val eventZaps = zapsByEvent[event.id] ?: emptyList()
NoteActionsRow(
event = event,
relayManager = relayManager,
localCache = localCache,
account = account,
onReplyClick = { /* TODO: Open reply dialog */ },
nwcConnection = nwcConnection,
onReplyClick = { onReply(event) },
onZapFeedback = onZapFeedback,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
zapCount = eventZaps.size,
zapAmountSats = eventZaps.sumOf { it.amountSats },
zapReceipts = eventZaps,
reactionCount = reactionsByEvent[event.id] ?: 0,
replyCount = repliesByEvent[event.id] ?: 0,
repostCount = repostsByEvent[event.id] ?: 0,
bookmarkList = bookmarkList,
isBookmarked = bookmarkedEventIds.contains(event.id),
onBookmarkChanged = { newList ->
bookmarkList = newList
val pubIds =
newList
.publicBookmarks()
.filterIsInstance<EventBookmark>()
.map { it.eventId }
.toSet()
bookmarkedEventIds = pubIds
},
)
}
}
@@ -35,6 +35,8 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material.icons.filled.PersonRemove
import androidx.compose.material3.Button
@@ -46,6 +48,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -60,20 +63,26 @@ import com.vitorpamplona.amethyst.commons.account.AccountState
import com.vitorpamplona.amethyst.commons.model.nip02FollowList.FollowAction
import com.vitorpamplona.amethyst.commons.state.EventCollectionState
import com.vitorpamplona.amethyst.commons.state.FollowState
import com.vitorpamplona.amethyst.commons.subscriptions.FilterBuilders
import com.vitorpamplona.amethyst.commons.subscriptions.SubscriptionConfig
import com.vitorpamplona.amethyst.commons.subscriptions.createContactListSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.createMetadataSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.createUserPostsSubscription
import com.vitorpamplona.amethyst.commons.subscriptions.rememberSubscription
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
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.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.awt.Toolkit
import java.awt.datatransfer.StringSelection
/**
* User profile screen showing user info, follow button, and their posts.
@@ -82,10 +91,13 @@ import kotlinx.coroutines.withContext
fun UserProfileScreen(
pubKeyHex: String,
relayManager: DesktopRelayConnectionManager,
localCache: DesktopLocalCache,
account: AccountState.LoggedIn?,
nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null,
onBack: () -> Unit,
onCompose: () -> Unit = {},
onNavigateToProfile: (String) -> Unit = {},
onZapFeedback: (ZapFeedback) -> Unit = {},
) {
val connectedRelays by relayManager.connectedRelays.collectAsState()
val relayStatuses by relayManager.relayStatuses.collectAsState()
@@ -188,6 +200,61 @@ fun UserProfileScreen(
}
}
// Subscribe to profile user's contact list (for following count)
rememberSubscription(relayStatuses, pubKeyHex, retryTrigger, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isNotEmpty()) {
createContactListSubscription(
relays = configuredRelays,
pubKeyHex = pubKeyHex,
onEvent = { event, _, _, _ ->
if (event is ContactListEvent) {
// Count the number of people this user follows
followingCount = event.verifiedFollowKeySet().size
}
},
onEose = { _, _ -> },
)
} else {
null
}
}
// Track unique followers (authors of contact lists that tag this pubkey)
val followerAuthors = remember(pubKeyHex) { mutableSetOf<String>() }
// Subscribe to followers (contact lists that tag this user)
rememberSubscription(relayStatuses, pubKeyHex, retryTrigger, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isNotEmpty()) {
// Clear previous followers when subscription restarts
followerAuthors.clear()
followersCount = 0
SubscriptionConfig(
subId = "followers-${pubKeyHex.take(8)}-${System.currentTimeMillis()}",
filters =
listOf(
FilterBuilders.byPTags(
pubKeys = listOf(pubKeyHex),
kinds = listOf(3), // ContactListEvent
limit = 500,
),
),
relays = configuredRelays,
onEvent = { event, _, _, _ ->
// Count unique authors who follow this user
if (followerAuthors.add(event.pubKey)) {
followersCount = followerAuthors.size
}
},
onEose = { _, _ -> },
)
} else {
null
}
}
// Subscribe to user posts
rememberSubscription(relayStatuses, pubKeyHex, retryTrigger, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
@@ -339,11 +406,49 @@ fun UserProfileScreen(
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,
)
val npub = pubKeyHex.hexToByteArrayOrNull()?.toNpub()
var copied by remember { mutableStateOf(false) }
// Reset copied state after delay
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
},
)
}
}
}
}
}
@@ -461,8 +566,11 @@ fun UserProfileScreen(
FeedNoteCard(
event = event,
relayManager = relayManager,
localCache = localCache,
account = account,
nwcConnection = nwcConnection,
onReply = onCompose,
onZapFeedback = onZapFeedback,
onNavigateToProfile = onNavigateToProfile,
)
}