Merge pull request #1676 from nrobi144/nrobi144/phase-2A

Desktop: Basic Search + Bookmarks + Zaps
This commit is contained in:
Vitor Pamplona
2026-01-26 09:53:35 -05:00
committed by GitHub
106 changed files with 6907 additions and 322 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.desktop.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
@@ -74,18 +79,24 @@ import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState
import com.vitorpamplona.amethyst.commons.ui.screens.MessagesPlaceholder
import com.vitorpamplona.amethyst.commons.ui.screens.SearchPlaceholder
import com.vitorpamplona.amethyst.desktop.account.AccountManager
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
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.amethyst.desktop.ui.profile.ProfileInfoCard
import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -99,8 +110,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 +142,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 +171,7 @@ fun main() =
} else {
KeyShortcut(Key.Comma, ctrl = true)
},
onClick = { /* TODO: Open settings */ },
onClick = { currentScreen = DesktopScreen.Settings },
)
Separator()
Item(
@@ -202,25 +219,50 @@ 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) }
// Subscriptions coordinator for metadata/reactions loading
val subscriptionsCoordinator =
remember(relayManager, localCache) {
DesktopRelaySubscriptionsCoordinator(
client = relayManager.client,
scope = scope,
indexRelays = relayManager.availableRelays.value,
localCache = localCache,
)
}
// Try to load saved account on startup
DisposableEffect(Unit) {
scope.launch(Dispatchers.IO) {
@@ -230,7 +272,12 @@ fun App(
relayManager.addDefaultRelays()
relayManager.connect()
// Start subscriptions coordinator
subscriptionsCoordinator.start()
onDispose {
subscriptionsCoordinator.clear()
relayManager.disconnect()
}
}
@@ -246,19 +293,29 @@ 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,
subscriptionsCoordinator = subscriptionsCoordinator,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
)
// Compose dialog
@@ -267,6 +324,7 @@ fun App(
onDismiss = onDismissComposeDialog,
relayManager = relayManager,
account = account,
replyTo = replyToNote,
)
}
}
@@ -280,127 +338,225 @@ fun MainContent(
currentScreen: DesktopScreen,
onScreenChange: (DesktopScreen) -> Unit,
relayManager: DesktopRelayConnectionManager,
localCache: DesktopLocalCache,
accountManager: AccountManager,
account: AccountState.LoggedIn,
nwcConnection: Nip47WalletConnect.Nip47URINorm?,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
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,
subscriptionsCoordinator = subscriptionsCoordinator,
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,
subscriptionsCoordinator = subscriptionsCoordinator,
onNavigateToProfile = { pubKeyHex ->
onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
},
onNavigateToThread = { noteId ->
onScreenChange(DesktopScreen.Thread(noteId))
},
)
DesktopScreen.Bookmarks ->
BookmarksScreen(
relayManager = relayManager,
localCache = localCache,
account = account,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
onNavigateToProfile = { pubKeyHex ->
onScreenChange(DesktopScreen.UserProfile(pubKeyHex))
},
onNavigateToThread = { noteId ->
onScreenChange(DesktopScreen.Thread(noteId))
},
onZapFeedback = onZapFeedback,
)
DesktopScreen.Messages -> MessagesPlaceholder()
DesktopScreen.Notifications -> NotificationsScreen(relayManager, account, subscriptionsCoordinator)
DesktopScreen.MyProfile ->
UserProfileScreen(
pubKeyHex = account.pubKeyHex,
relayManager = relayManager,
localCache = localCache,
account = account,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
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,
subscriptionsCoordinator = subscriptionsCoordinator,
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,
subscriptionsCoordinator = subscriptionsCoordinator,
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 +599,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 +622,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
@@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.nip19Bech32.decodePrivateKeyAsHexOrNull
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip19Bech32.toNsec
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -69,6 +70,9 @@ class AccountManager private constructor(
private val _accountState = MutableStateFlow<AccountState>(AccountState.LoggedOut)
val accountState: StateFlow<AccountState> = _accountState.asStateFlow()
private val _nwcConnection = MutableStateFlow<Nip47WalletConnect.Nip47URINorm?>(null)
val nwcConnection: StateFlow<Nip47WalletConnect.Nip47URINorm?> = _nwcConnection.asStateFlow()
/**
* Loads the last saved account from secure storage.
* Call on app startup.
@@ -206,6 +210,47 @@ class AccountManager private constructor(
fun currentAccount(): AccountState.LoggedIn? = _accountState.value as? AccountState.LoggedIn
// NWC (Nostr Wallet Connect) methods
fun hasNwcSetup(): Boolean = _nwcConnection.value != null
fun setNwcConnection(uri: String): Result<Nip47WalletConnect.Nip47URINorm> =
try {
val parsed = Nip47WalletConnect.parse(uri)
_nwcConnection.value = parsed
saveNwcUri(uri)
Result.success(parsed)
} catch (e: Exception) {
Result.failure(e)
}
fun clearNwcConnection() {
_nwcConnection.value = null
getNwcFile().delete()
}
fun loadNwcConnection() {
val uri = getNwcFile().takeIf { it.exists() }?.readText()?.trim()
if (!uri.isNullOrEmpty()) {
try {
_nwcConnection.value = Nip47WalletConnect.parse(uri)
} catch (e: Exception) {
// Invalid stored URI, clear it
getNwcFile().delete()
}
}
}
private fun saveNwcUri(uri: String) {
val file = getNwcFile()
file.parentFile?.mkdirs()
file.writeText(uri)
}
private fun getNwcFile(): java.io.File {
val homeDir = System.getProperty("user.home")
return java.io.File(homeDir, ".amethyst/nwc_connection.txt")
}
// Simple file-based storage for last npub (non-sensitive data)
private fun getLastNpub(): String? {
val file = getPrefsFile()
@@ -0,0 +1,279 @@
/**
* 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.AddressableNote
import com.vitorpamplona.amethyst.commons.model.Channel
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.services.nwc.NwcPaymentTracker
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
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 addressableNotes = ConcurrentHashMap<String, AddressableNote>()
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)
}
override fun countUsers(predicate: (String, User) -> 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)
}
override fun getOrCreateAddressableNote(key: Address): AddressableNote =
addressableNotes.getOrPut(key.toValue()) {
AddressableNote(key)
}
// ----- Channel operations -----
override fun getAnyChannel(note: Note): Channel? {
// 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 Event -> deletedEvents.contains(event.id)
else -> false
}
fun markAsDeleted(eventId: HexKey) {
deletedEvents.add(eventId)
}
// ----- Own event consumption -----
override fun justConsumeMyOwnEvent(event: Event): Boolean {
// Desktop doesn't track own events separately
return false
}
// ----- 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()
addressableNotes.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)
}
}
@@ -44,24 +44,27 @@ import kotlinx.coroutines.flow.asStateFlow
open class RelayConnectionManager(
websocketBuilder: WebsocketBuilder,
) : IRelayClientListener {
private val client = NostrClient(websocketBuilder)
private val _client = NostrClient(websocketBuilder)
/** Exposes the underlying INostrClient for subscription coordinators */
val client: com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient get() = _client
private val _relayStatuses = MutableStateFlow<Map<NormalizedRelayUrl, RelayStatus>>(emptyMap())
val relayStatuses: StateFlow<Map<NormalizedRelayUrl, RelayStatus>> = _relayStatuses.asStateFlow()
val connectedRelays: StateFlow<Set<NormalizedRelayUrl>> = client.connectedRelaysFlow()
val availableRelays: StateFlow<Set<NormalizedRelayUrl>> = client.availableRelaysFlow()
val connectedRelays: StateFlow<Set<NormalizedRelayUrl>> = _client.connectedRelaysFlow()
val availableRelays: StateFlow<Set<NormalizedRelayUrl>> = _client.availableRelaysFlow()
init {
client.subscribe(this)
_client.subscribe(this)
}
fun connect() {
client.connect()
_client.connect()
}
fun disconnect() {
client.disconnect()
_client.disconnect()
}
fun addRelay(url: String): NormalizedRelayUrl? {
@@ -85,18 +88,18 @@ open class RelayConnectionManager(
listener: IRequestListener? = null,
) {
val filterMap = relays.associateWith { filters }
client.openReqSubscription(subId, filterMap, listener)
_client.openReqSubscription(subId, filterMap, listener)
}
fun unsubscribe(subId: String) {
client.close(subId)
_client.close(subId)
}
fun send(
event: Event,
relays: Set<NormalizedRelayUrl> = connectedRelays.value,
) {
client.send(event, relays)
_client.send(event, relays)
}
/**
@@ -107,6 +110,61 @@ open class RelayConnectionManager(
send(event, connected)
}
/**
* Sends an event to a specific relay (for NWC).
* Adds the relay if not already in the list.
*/
fun sendToRelay(
relay: NormalizedRelayUrl,
event: Event,
) {
if (relay !in availableRelays.value) {
updateRelayStatus(relay) { it.copy(connected = false, error = null) }
}
_client.send(event, setOf(relay))
}
/**
* Subscribes on a specific relay (for NWC).
* Adds the relay if not already in the list.
*/
fun subscribeOnRelay(
relay: NormalizedRelayUrl,
subId: String,
filters: List<Filter>,
onEvent: (Event, NormalizedRelayUrl) -> Unit,
) {
if (relay !in availableRelays.value) {
updateRelayStatus(relay) { it.copy(connected = false, error = null) }
}
val filterMap = mapOf(relay to filters)
_client.openReqSubscription(
subId = subId,
filters = filterMap,
listener =
object : IRequestListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
onEvent(event, relay)
}
},
)
}
/**
* Closes a subscription on a specific relay.
*/
fun closeSubscription(
relay: NormalizedRelayUrl,
subId: String,
) {
_client.close(subId)
}
private fun updateRelayStatus(
url: NormalizedRelayUrl,
update: (RelayStatus) -> RelayStatus,
@@ -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,141 @@
/**
* 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.subscriptions
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.FeedMetadataCoordinator
import com.vitorpamplona.amethyst.commons.relayClient.preload.MetadataPreloader
import com.vitorpamplona.amethyst.commons.relayClient.preload.MetadataRateLimiter
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.CoroutineScope
/**
* Desktop-specific relay subscriptions coordinator.
* Manages metadata and reactions loading with rate limiting and prioritization.
*
* This coordinator ensures:
* - Display names and avatars load before reactions
* - Metadata requests are rate-limited (20/sec) to avoid relay flooding
* - Subscriptions are batched efficiently
*
* Usage:
* ```
* val coordinator = DesktopRelaySubscriptionsCoordinator(
* client = relayManager.client,
* scope = viewModelScope,
* indexRelays = relayManager.availableRelays.value,
* )
* coordinator.start()
*
* // In screens:
* LaunchedEffect(notes) {
* coordinator.loadMetadataForNotes(notes)
* }
* ```
*/
class DesktopRelaySubscriptionsCoordinator(
private val client: INostrClient,
private val scope: CoroutineScope,
private val indexRelays: Set<NormalizedRelayUrl>,
private val localCache: DesktopLocalCache,
) {
// Rate limiter: 20 requests per second to avoid flooding relays
private val rateLimiter = MetadataRateLimiter(maxRequestsPerSecond = 20, scope = scope)
// Preloader handles metadata + avatar prefetching
private val preloader = MetadataPreloader(rateLimiter, imagePrefetcher = null)
// Feed metadata coordinator with priority queue
val feedMetadata =
FeedMetadataCoordinator(
client = client,
scope = scope,
indexRelays = indexRelays,
preloader = preloader,
onEvent = { event, _ ->
// Consume metadata events into local cache
if (event is MetadataEvent) {
localCache.consumeMetadata(event)
}
},
)
/**
* Start the coordinator.
* Call once when app starts or user logs in.
*/
fun start() {
// Start rate limiter to process queued metadata requests
rateLimiter.start { pubkey ->
// When rate limiter dequeues a pubkey, subscribe to its metadata
client.openReqSubscription(
filters =
indexRelays.associateWith {
listOf(
com.vitorpamplona.quartz.nip01Core.relay.filters.Filter(
kinds = listOf(com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent.KIND),
authors = listOf(pubkey),
limit = 1,
),
)
},
)
}
// Start feed metadata coordinator
feedMetadata.start()
}
/**
* Load metadata and reactions for notes.
* Delegates to FeedMetadataCoordinator.
*/
fun loadMetadataForNotes(notes: List<Note>) {
feedMetadata.loadMetadataForNotes(notes)
}
/**
* Load metadata for specific pubkeys.
*/
fun loadMetadataForPubkeys(pubkeys: List<HexKey>) {
feedMetadata.loadMetadataForPubkeys(pubkeys)
}
/**
* Load reactions for specific notes.
*/
fun loadReactionsForNotes(noteIds: List<HexKey>) {
feedMetadata.loadReactionsForNotes(noteIds)
}
/**
* Clear all queued requests.
* Call when switching accounts or during cleanup.
*/
fun clear() {
feedMetadata.clear()
rateLimiter.reset()
}
}
@@ -131,3 +131,187 @@ fun createThreadRepliesSubscription(
onEvent = onEvent,
onEose = onEose,
)
/**
* Creates a NIP-50 search subscription for user profiles.
* Requires NIP-50 compatible relays (e.g., relay.nostr.band, nostr.wine).
*
* @param searchQuery Text to search for in user profiles
* @param limit Maximum results to return
*/
fun createSearchPeopleSubscription(
relays: Set<NormalizedRelayUrl>,
searchQuery: String,
limit: Int = 50,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig? {
if (searchQuery.isBlank()) return null
return SubscriptionConfig(
subId = generateSubId("search-people-${searchQuery.take(8)}"),
filters = listOf(FilterBuilders.searchPeople(searchQuery, limit)),
relays = relays,
onEvent = onEvent,
onEose = onEose,
)
}
/**
* Creates a NIP-50 search subscription for text notes.
* Requires NIP-50 compatible relays.
*
* @param searchQuery Text to search for in notes
* @param limit Maximum results to return
*/
fun createSearchNotesSubscription(
relays: Set<NormalizedRelayUrl>,
searchQuery: String,
limit: Int = 50,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig? {
if (searchQuery.isBlank()) return null
return SubscriptionConfig(
subId = generateSubId("search-notes-${searchQuery.take(8)}"),
filters = listOf(FilterBuilders.searchNotes(searchQuery, limit)),
relays = relays,
onEvent = onEvent,
onEose = onEose,
)
}
/**
* Creates a subscription for zap receipts (kind 9735) for specific events.
*
* @param eventIds Event IDs to get zaps for
* @param limit Maximum zaps per event
*/
fun createZapsSubscription(
relays: Set<NormalizedRelayUrl>,
eventIds: List<String>,
limit: Int = 100,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig? {
if (eventIds.isEmpty()) return null
return SubscriptionConfig(
subId = generateSubId("zaps-${eventIds.first().take(8)}"),
filters = listOf(FilterBuilders.zapsForEvents(eventIds, limit)),
relays = relays,
onEvent = onEvent,
onEose = onEose,
)
}
/**
* Creates a subscription for reactions (kind 7) for specific events.
*
* @param eventIds Event IDs to get reactions for
* @param limit Maximum reactions per event
*/
fun createReactionsSubscription(
relays: Set<NormalizedRelayUrl>,
eventIds: List<String>,
limit: Int = 100,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig? {
if (eventIds.isEmpty()) return null
return SubscriptionConfig(
subId = generateSubId("reactions-${eventIds.first().take(8)}"),
filters = listOf(FilterBuilders.reactionsForEvents(eventIds, limit)),
relays = relays,
onEvent = onEvent,
onEose = onEose,
)
}
/**
* Creates a subscription for replies (kind 1) to specific events.
*
* @param eventIds Event IDs to get replies for
* @param limit Maximum replies per event
*/
fun createRepliesSubscription(
relays: Set<NormalizedRelayUrl>,
eventIds: List<String>,
limit: Int = 100,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig? {
if (eventIds.isEmpty()) return null
return SubscriptionConfig(
subId = generateSubId("replies-${eventIds.first().take(8)}"),
filters = listOf(FilterBuilders.repliesForEvents(eventIds, limit)),
relays = relays,
onEvent = onEvent,
onEose = onEose,
)
}
/**
* Creates a subscription for reposts (kind 6) of specific events.
*
* @param eventIds Event IDs to get reposts for
* @param limit Maximum reposts per event
*/
fun createRepostsSubscription(
relays: Set<NormalizedRelayUrl>,
eventIds: List<String>,
limit: Int = 100,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig? {
if (eventIds.isEmpty()) return null
return SubscriptionConfig(
subId = generateSubId("reposts-${eventIds.first().take(8)}"),
filters = listOf(FilterBuilders.repostsForEvents(eventIds, limit)),
relays = relays,
onEvent = onEvent,
onEose = onEose,
)
}
/**
* Creates a subscription config for global long-form content (kind 30023, NIP-23).
*/
fun createLongFormFeedSubscription(
relays: Set<NormalizedRelayUrl>,
limit: Int = 30,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig =
SubscriptionConfig(
subId = generateSubId("longform-feed"),
filters = listOf(FilterBuilders.longFormGlobal(limit = limit)),
relays = relays,
onEvent = onEvent,
onEose = onEose,
)
/**
* Creates a subscription config for long-form content from followed users.
*/
fun createFollowingLongFormFeedSubscription(
relays: Set<NormalizedRelayUrl>,
followedUsers: List<String>,
limit: Int = 30,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig? {
if (followedUsers.isEmpty()) return null
return SubscriptionConfig(
subId = generateSubId("longform-following"),
filters = listOf(FilterBuilders.longFormFromAuthors(followedUsers, limit = limit)),
relays = relays,
onEvent = onEvent,
onEose = onEose,
)
}
@@ -83,6 +83,18 @@ object FilterBuilders {
limit = 1,
)
/**
* Creates a filter for user metadata (kind 0) from multiple authors.
*
* @param pubKeyHexList List of author public keys (hex-encoded, 64 chars each)
* @return Filter for user metadata
*/
fun userMetadataBatch(pubKeyHexList: List<String>): Filter =
Filter(
kinds = listOf(0), // MetadataEvent.KIND
authors = pubKeyHexList,
)
/**
* Creates a filter for contact list (kind 3) from a specific author.
*
@@ -256,6 +268,155 @@ object FilterBuilders {
since = since,
until = until,
)
/**
* Creates a NIP-50 search filter for user metadata (kind 0).
* Searches user profiles by name, displayName, about, nip05, etc.
* Requires a NIP-50 compatible relay (e.g., relay.nostr.band, nostr.wine).
*
* @param searchQuery The text to search for in user profiles
* @param limit Maximum number of results to return
* @return Filter for NIP-50 search
*/
fun searchPeople(
searchQuery: String,
limit: Int = 50,
): Filter =
Filter(
kinds = listOf(0), // MetadataEvent.KIND
search = searchQuery,
limit = limit,
)
/**
* Creates a NIP-50 search filter for text notes (kind 1).
* Searches note content.
* Requires a NIP-50 compatible relay.
*
* @param searchQuery The text to search for in notes
* @param limit Maximum number of results to return
* @return Filter for NIP-50 search
*/
fun searchNotes(
searchQuery: String,
limit: Int = 50,
): Filter =
Filter(
kinds = listOf(1), // TextNoteEvent.KIND
search = searchQuery,
limit = limit,
)
/**
* Creates a filter for zap receipts (kind 9735) for specific events.
*
* @param eventIds List of event IDs to get zaps for
* @param limit Maximum number of events to request
* @return Filter for zap receipts
*/
fun zapsForEvents(
eventIds: List<String>,
limit: Int? = null,
): Filter =
Filter(
kinds = listOf(9735), // LnZapEvent.KIND
tags = mapOf("e" to eventIds),
limit = limit,
)
/**
* Creates a filter for reactions (kind 7) for specific events.
*
* @param eventIds List of event IDs to get reactions for
* @param limit Maximum number of events to request
* @return Filter for reactions
*/
fun reactionsForEvents(
eventIds: List<String>,
limit: Int? = null,
): Filter =
Filter(
kinds = listOf(7), // ReactionEvent.KIND
tags = mapOf("e" to eventIds),
limit = limit,
)
/**
* Creates a filter for replies (kind 1) to specific events.
*
* @param eventIds List of event IDs to get replies for
* @param limit Maximum number of events to request
* @return Filter for replies
*/
fun repliesForEvents(
eventIds: List<String>,
limit: Int? = null,
): Filter =
Filter(
kinds = listOf(1), // TextNoteEvent.KIND
tags = mapOf("e" to eventIds),
limit = limit,
)
/**
* Creates a filter for reposts (kind 6) of specific events.
*
* @param eventIds List of event IDs to get reposts for
* @param limit Maximum number of events to request
* @return Filter for reposts
*/
fun repostsForEvents(
eventIds: List<String>,
limit: Int? = null,
): Filter =
Filter(
kinds = listOf(6), // RepostEvent.KIND
tags = mapOf("e" to eventIds),
limit = limit,
)
/**
* Creates a filter for long-form content (kind 30023, NIP-23).
*
* @param limit Maximum number of events to request
* @param since Timestamp for events with publication time ≥ this value
* @param until Timestamp for events with publication time ≤ this value
* @return Filter for long-form content
*/
fun longFormGlobal(
limit: Int? = null,
since: Long? = null,
until: Long? = null,
): Filter =
Filter(
kinds = listOf(30023), // LongTextNoteEvent.KIND
limit = limit,
since = since,
until = until,
)
/**
* Creates a filter for long-form content (kind 30023) from specific authors.
*
* @param authors List of author public keys (hex-encoded, 64 chars each)
* @param limit Maximum number of events to request
* @param since Timestamp for events with publication time ≥ this value
* @param until Timestamp for events with publication time ≤ this value
* @return Filter for long-form content from specified authors
*/
fun longFormFromAuthors(
authors: List<String>,
limit: Int? = null,
since: Long? = null,
until: Long? = null,
): Filter =
Filter(
kinds = listOf(30023), // LongTextNoteEvent.KIND
authors = authors,
limit = limit,
since = since,
until = until,
)
}
/**
@@ -26,20 +26,50 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/**
* Creates a subscription config for user metadata (kind 0).
* Returns null if the pubKeyHex is invalid (not 64 characters).
*/
fun createMetadataSubscription(
relays: Set<NormalizedRelayUrl>,
pubKeyHex: String,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig =
SubscriptionConfig(
): SubscriptionConfig? {
// Validate pubkey length
if (pubKeyHex.length != 64) {
return null
}
return SubscriptionConfig(
subId = generateSubId("meta-${pubKeyHex.take(8)}"),
filters = listOf(FilterBuilders.userMetadata(pubKeyHex)),
relays = relays,
onEvent = onEvent,
onEose = onEose,
)
}
/**
* Creates a subscription config for metadata of multiple users (kind 0).
* Useful for batch-fetching author profiles.
* Filters out any invalid pubkeys (not 64 characters).
*/
fun createBatchMetadataSubscription(
relays: Set<NormalizedRelayUrl>,
pubKeyHexList: List<String>,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig? {
// Filter out invalid pubkeys
val validPubkeys = pubKeyHexList.filter { it.length == 64 }
if (validPubkeys.isEmpty()) return null
return SubscriptionConfig(
subId = generateSubId("meta-batch-${validPubkeys.size}"),
filters = listOf(FilterBuilders.userMetadataBatch(validPubkeys)),
relays = relays,
onEvent = onEvent,
onEose = onEose,
)
}
/**
* Creates a subscription config for user posts (kind 1).
@@ -0,0 +1,347 @@
/**
* 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.state.EventCollectionState
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders
import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard
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,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = 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()
// Load metadata for bookmark authors via coordinator
LaunchedEffect(publicEvents, privateEvents, subscriptionsCoordinator) {
if (subscriptionsCoordinator != null) {
val pubkeys = (publicEvents + privateEvents).map { it.pubKey }.distinct()
if (pubkeys.isNotEmpty()) {
subscriptionsCoordinator.loadMetadataForPubkeys(pubkeys)
}
}
}
// 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)
}
}
}
}
}
}
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.desktop.ui
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.desktop.ui.note.NoteDisplayData
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
@@ -28,7 +30,7 @@ import com.vitorpamplona.quartz.nip19Bech32.toNpub
/**
* Extension to convert Event to NoteDisplayData for the shared NoteCard.
*/
fun Event.toNoteDisplayData(): NoteDisplayData {
fun Event.toNoteDisplayData(cache: ICacheProvider? = null): NoteDisplayData {
val npub =
try {
pubKey.hexToByteArrayOrNull()?.toNpub() ?: pubKey.take(16) + "..."
@@ -36,10 +38,13 @@ fun Event.toNoteDisplayData(): NoteDisplayData {
pubKey.take(16) + "..."
}
val pictureUrl = (cache?.getUserIfExists(pubKey) as? User)?.profilePicture()
return NoteDisplayData(
id = id,
pubKeyHex = pubKey,
pubKeyDisplay = npub,
profilePictureUrl = pictureUrl,
content = content,
createdAt = createdAt,
)
@@ -43,6 +43,7 @@ import androidx.compose.material3.IconButton
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
@@ -53,17 +54,33 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.state.EventCollectionState
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
import com.vitorpamplona.amethyst.desktop.DesktopPreferences
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode
import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders
import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig
import com.vitorpamplona.amethyst.desktop.subscriptions.createBatchMetadataSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createFollowingFeedSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createGlobalFeedSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createReactionsSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createRepliesSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createRepostsSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createZapsSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard
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.
@@ -72,11 +89,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 {
@@ -84,7 +113,7 @@ fun FeedNoteCard(
},
) {
NoteCard(
note = event.toNoteDisplayData(),
note = event.toNoteDisplayData(localCache),
onAuthorClick = onNavigateToProfile,
)
@@ -93,9 +122,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,
)
}
}
@@ -104,10 +145,14 @@ fun FeedNoteCard(
@Composable
fun FeedScreen(
relayManager: DesktopRelayConnectionManager,
localCache: DesktopLocalCache,
account: AccountState.LoggedIn? = null,
nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
onCompose: () -> Unit = {},
onNavigateToProfile: (String) -> Unit = {},
onNavigateToThread: (String) -> Unit = {},
onZapFeedback: (ZapFeedback) -> Unit = {},
) {
val connectedRelays by relayManager.connectedRelays.collectAsState()
val relayStatuses by relayManager.relayStatuses.collectAsState()
@@ -123,8 +168,23 @@ 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()) }
// Track reaction event IDs per target event to deduplicate
var reactionIdsByEvent by remember { mutableStateOf<Map<String, Set<String>>>(emptyMap()) }
val reactionsByEvent = reactionIdsByEvent.mapValues { it.value.size }
// Track reply/repost event IDs per target event to deduplicate
var replyIdsByEvent by remember { mutableStateOf<Map<String, Set<String>>>(emptyMap()) }
val repliesByEvent = replyIdsByEvent.mapValues { it.value.size }
var repostIdsByEvent by remember { mutableStateOf<Map<String, Set<String>>>(emptyMap()) }
val repostsByEvent = repostIdsByEvent.mapValues { it.value.size }
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) }
val initialLoadComplete = eoseReceivedCount > 0
// Load followed users for Following feed mode
rememberSubscription(relayStatuses, account, feedMode, relayManager = relayManager) {
@@ -144,9 +204,45 @@ fun FeedScreen(
}
}
// Clear events when feed mode changes
// Load user's bookmark list
rememberSubscription(relayStatuses, account, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isNotEmpty() && account != null) {
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()
eoseReceivedCount = 0
}
// Subscribe to feed based on mode
@@ -161,8 +257,15 @@ fun FeedScreen(
createGlobalFeedSubscription(
relays = configuredRelays,
onEvent = { event, _, _, _ ->
// Store metadata events in cache
if (event is MetadataEvent) {
localCache.consumeMetadata(event)
}
eventState.addItem(event)
},
onEose = { _, _ ->
eoseReceivedCount++
},
)
}
FeedMode.FOLLOWING -> {
@@ -171,8 +274,15 @@ 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 = { _, _ ->
eoseReceivedCount++
},
)
} else {
null
@@ -181,6 +291,181 @@ 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
reactionIdsByEvent =
reactionIdsByEvent.toMutableMap().apply {
val existing = this[targetEventId] ?: emptySet()
this[targetEventId] = existing + event.id
}
}
},
)
}
// 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) {
replyIdsByEvent =
replyIdsByEvent.toMutableMap().apply {
val existing = this[replyToId] ?: emptySet()
this[replyToId] = existing + event.id
}
}
},
)
}
// 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
repostIdsByEvent =
repostIdsByEvent.toMutableMap().apply {
val existing = this[targetEventId] ?: emptySet()
this[targetEventId] = existing + event.id
}
}
},
)
}
// Subscribe to metadata for note authors (to enable zaps and populate search cache)
val authorPubkeys = events.map { it.pubKey }.distinct()
// Use coordinator for rate-limited metadata loading (preferred)
LaunchedEffect(authorPubkeys, subscriptionsCoordinator) {
if (subscriptionsCoordinator != null && authorPubkeys.isNotEmpty()) {
subscriptionsCoordinator.loadMetadataForPubkeys(authorPubkeys)
}
}
// Fallback subscription if coordinator not available
rememberSubscription(relayStatuses, authorPubkeys, subscriptionsCoordinator, relayManager = relayManager) {
// Skip if using coordinator
if (subscriptionsCoordinator != null) {
return@rememberSubscription null
}
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(
@@ -204,12 +489,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") },
)
}
@@ -262,13 +553,23 @@ fun FeedScreen(
LoadingState("Connecting to relays...")
} else if (feedMode == FeedMode.FOLLOWING && followedUsers.isEmpty()) {
LoadingState("Loading followed users...")
} else if (events.isEmpty()) {
LoadingState(
if (feedMode == FeedMode.FOLLOWING) {
"No notes from followed users yet"
} else {
"Loading notes..."
},
} else if (events.isEmpty() && !initialLoadComplete) {
LoadingState("Loading notes...")
} else if (events.isEmpty() && initialLoadComplete) {
EmptyState(
title =
if (feedMode == FeedMode.FOLLOWING) {
"No notes from followed users"
} else {
"No notes found"
},
description =
if (feedMode == FeedMode.FOLLOWING) {
"Notes from people you follow will appear here"
} else {
"Notes from the network will appear here"
},
onRefresh = { relayManager.connect() },
)
} else {
LazyColumn(
@@ -278,10 +579,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
@@ -39,10 +39,13 @@ import androidx.compose.material3.Icon
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
@@ -50,11 +53,13 @@ import com.vitorpamplona.amethyst.commons.icons.Reply
import com.vitorpamplona.amethyst.commons.icons.Repost
import com.vitorpamplona.amethyst.commons.icons.Zap
import com.vitorpamplona.amethyst.commons.state.EventCollectionState
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
import com.vitorpamplona.amethyst.commons.ui.feed.FeedHeader
import com.vitorpamplona.amethyst.commons.util.toTimeAgo
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.subscriptions.createNotificationsSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -105,6 +110,7 @@ sealed class NotificationItem(
fun NotificationsScreen(
relayManager: DesktopRelayConnectionManager,
account: AccountState.LoggedIn,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
) {
val connectedRelays by relayManager.connectedRelays.collectAsState()
val relayStatuses by relayManager.relayStatuses.collectAsState()
@@ -120,6 +126,18 @@ fun NotificationsScreen(
}
val notifications by notificationState.items.collectAsState()
// Load metadata for notification authors via coordinator
LaunchedEffect(notifications, subscriptionsCoordinator) {
if (subscriptionsCoordinator != null && notifications.isNotEmpty()) {
val pubkeys = notifications.map { it.event.pubKey }.distinct()
subscriptionsCoordinator.loadMetadataForPubkeys(pubkeys)
}
}
// Track EOSE to know when initial load is complete
var eoseReceivedCount by remember { mutableStateOf(0) }
val initialLoadComplete = eoseReceivedCount > 0
// Subscribe to notifications
rememberSubscription(relayStatuses, account.pubKeyHex, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
@@ -168,6 +186,9 @@ fun NotificationsScreen(
notificationState.addItem(notification)
},
onEose = { _, _ ->
eoseReceivedCount++
},
)
} else {
null
@@ -185,8 +206,14 @@ fun NotificationsScreen(
if (connectedRelays.isEmpty()) {
LoadingState("Connecting to relays...")
} else if (notifications.isEmpty()) {
} else if (notifications.isEmpty() && !initialLoadComplete) {
LoadingState("Loading notifications...")
} else if (notifications.isEmpty() && initialLoadComplete) {
EmptyState(
title = "No notifications yet",
description = "When someone interacts with your posts, you'll see it here",
onRefresh = { relayManager.connect() },
)
} else {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
@@ -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.state.EventCollectionState
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode
import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createFollowingLongFormFeedSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createLongFormFeedSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
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,453 @@
/**
* 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.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.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.subscriptions.createMetadataSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createSearchPeopleSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
@Composable
fun SearchScreen(
localCache: DesktopLocalCache,
relayManager: DesktopRelayConnectionManager,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
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,
)
}
}
}
@@ -41,6 +41,7 @@ import androidx.compose.material3.IconButton
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
@@ -51,15 +52,29 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.state.EventCollectionState
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
import com.vitorpamplona.amethyst.commons.ui.thread.drawReplyLevel
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders
import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig
import com.vitorpamplona.amethyst.desktop.subscriptions.createNoteSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createReactionsSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createRepliesSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createRepostsSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createThreadRepliesSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createZapsSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard
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.
@@ -70,10 +85,15 @@ 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,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = 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()
@@ -97,6 +117,71 @@ fun ThreadScreen(
// Cache for calculating reply levels
val levelCache = remember(noteId) { mutableMapOf<String, Int>() }
// Track EOSE to know when initial load is complete
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()) }
// Track reaction event IDs per target event to deduplicate
var reactionIdsByEvent by remember(noteId) { mutableStateOf<Map<String, Set<String>>>(emptyMap()) }
val reactionsByEvent = reactionIdsByEvent.mapValues { it.value.size }
// Track reply/repost event IDs per target event to deduplicate
var replyIdsByEvent by remember(noteId) { mutableStateOf<Map<String, Set<String>>>(emptyMap()) }
val repliesByEvent = replyIdsByEvent.mapValues { it.value.size }
var repostIdsByEvent by remember(noteId) { mutableStateOf<Map<String, Set<String>>>(emptyMap()) }
val repostsByEvent = repostIdsByEvent.mapValues { it.value.size }
// Bookmark state
var bookmarkList by remember { mutableStateOf<BookmarkListEvent?>(null) }
var bookmarkedEventIds by remember { mutableStateOf<Set<String>>(emptySet()) }
// Load metadata for thread authors via coordinator
LaunchedEffect(rootNote, replyEvents, subscriptionsCoordinator) {
if (subscriptionsCoordinator != null) {
val pubkeys = mutableListOf<String>()
rootNote?.let { pubkeys.add(it.pubKey) }
pubkeys.addAll(replyEvents.map { it.pubKey })
if (pubkeys.isNotEmpty()) {
subscriptionsCoordinator.loadMetadataForPubkeys(pubkeys.distinct())
}
}
}
// 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
@@ -110,6 +195,9 @@ fun ThreadScreen(
levelCache[event.id] = 0
}
},
onEose = { _, _ ->
rootNoteEoseReceived = true
},
)
} else {
null
@@ -126,12 +214,115 @@ fun ThreadScreen(
onEvent = { event, _, _, _ ->
replyEventState.addItem(event)
},
onEose = { _, _ ->
repliesEoseReceived = true
},
)
} else {
null
}
}
// 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
reactionIdsByEvent =
reactionIdsByEvent.toMutableMap().apply {
val existing = this[targetEventId] ?: emptySet()
this[targetEventId] = existing + event.id
}
}
},
)
}
// 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) {
replyIdsByEvent =
replyIdsByEvent.toMutableMap().apply {
val existing = this[replyToId] ?: emptySet()
this[replyToId] = existing + event.id
}
}
},
)
}
// 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
repostIdsByEvent =
repostIdsByEvent.toMutableMap().apply {
val existing = this[targetEventId] ?: emptySet()
this[targetEventId] = existing + event.id
}
}
},
)
}
// Calculate reply level for an event based on e-tags
fun calculateLevel(event: Event): Int {
levelCache[event.id]?.let { return it }
@@ -171,8 +362,15 @@ fun ThreadScreen(
if (connectedRelays.isEmpty()) {
LoadingState("Connecting to relays...")
} else if (rootNote == null) {
} else if (rootNote == null && !rootNoteEoseReceived) {
LoadingState("Loading thread...")
} else if (rootNote == null && rootNoteEoseReceived) {
EmptyState(
title = "Note not found",
description = "This note may have been deleted or is not available from connected relays",
onRefresh = onBack,
refreshLabel = "Go back",
)
} else {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(0.dp),
@@ -186,16 +384,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
},
)
}
}
@@ -223,16 +443,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
},
)
}
}
@@ -240,7 +482,7 @@ fun ThreadScreen(
}
// Empty state for no replies
if (replyEvents.isEmpty()) {
if (replyEvents.isEmpty() && repliesEoseReceived) {
item {
Spacer(Modifier.height(32.dp))
Text(
@@ -250,6 +492,16 @@ fun ThreadScreen(
modifier = Modifier.padding(16.dp),
)
}
} else if (replyEvents.isEmpty() && !repliesEoseReceived) {
item {
Spacer(Modifier.height(32.dp))
Text(
"Loading replies...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp),
)
}
}
}
}
@@ -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
@@ -62,7 +65,11 @@ import com.vitorpamplona.amethyst.commons.state.FollowState
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders
import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig
import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createMetadataSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createUserPostsSubscription
@@ -72,8 +79,11 @@ 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 +92,14 @@ import kotlinx.coroutines.withContext
fun UserProfileScreen(
pubKeyHex: String,
relayManager: DesktopRelayConnectionManager,
localCache: DesktopLocalCache,
account: AccountState.LoggedIn?,
nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = 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 +202,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 +408,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 +568,11 @@ fun UserProfileScreen(
FeedNoteCard(
event = event,
relayManager = relayManager,
localCache = localCache,
account = account,
nwcConnection = nwcConnection,
onReply = onCompose,
onZapFeedback = onZapFeedback,
onNavigateToProfile = onNavigateToProfile,
)
}