This commit is contained in:
2026-05-19 08:58:03 +07:00
parent 08374fed49
commit fd64998fd8
5 changed files with 178 additions and 136 deletions

View File

@@ -8,6 +8,10 @@ import io.ktor.client.statement.HttpResponse
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import rust.nostr.sdk.AckPolicy
import rust.nostr.sdk.Alphabet
@@ -57,7 +61,9 @@ object NostrManager {
}
class Nostr {
private var isInitialized = false
private val _isInitialized = MutableStateFlow(false)
val isInitialized: StateFlow<Boolean> = _isInitialized.asStateFlow()
var client: Client? = null
private set
var signer: UniversalSigner = UniversalSigner(Keys.generate())
@@ -73,7 +79,7 @@ class Nostr {
suspend fun init(dbPath: String) {
try {
if (isInitialized) return
if (_isInitialized.value) return
// Initialize the logger for nostr client
initLogger(LogLevel.DEBUG)
@@ -97,33 +103,33 @@ class Nostr {
.sleepWhenIdle(SleepWhenIdle.Enabled(idleTimeout))
.build()
// Bootstrap relays
client?.addRelay(RelayUrl.parse("wss://relay.damus.io"))
client?.addRelay(RelayUrl.parse("wss://relay.primal.net"))
client?.addRelay(RelayUrl.parse("wss://user.kindpag.es"))
client?.addRelay(RelayUrl.parse("wss://purplepag.es"))
// Add search relay
client?.addRelay(
url = RelayUrl.parse("wss://antiprimal.net"),
capabilities = RelayCapabilities.read()
)
// Indexer relay for NIP-65 discovery
client?.addRelay(
url = RelayUrl.parse("wss://indexer.coracle.social"),
capabilities = RelayCapabilities.gossip()
)
// Connect to all bootstrap relays and wait for all connections to be established
client?.connect(Duration.parse("3s"))
isInitialized = true
_isInitialized.value = true
} catch (e: Exception) {
throw IllegalStateException("Failed to initialize Nostr client: ${e.message}", e)
}
}
suspend fun waitUntilInitialized() {
_isInitialized.first { it }
}
suspend fun connectBootstrapRelays() {
// Bootstrap relays
client?.addRelay(RelayUrl.parse("wss://relay.primal.net"))
client?.addRelay(RelayUrl.parse("wss://user.kindpag.es"))
client?.addRelay(RelayUrl.parse("wss://purplepag.es"))
// Indexer relay for NIP-65 discovery
client?.addRelay(
url = RelayUrl.parse("wss://indexer.coracle.social"),
capabilities = RelayCapabilities.gossip()
)
// Connect to all bootstrap relays and wait for all connections to be established
client?.connect(Duration.parse("2s"))
}
suspend fun disconnect() {
client?.shutdown()
}
@@ -773,6 +779,13 @@ class Nostr {
suspend fun searchByNostr(query: String): List<PublicKey> {
try {
// Add search relay
val searchRelay = RelayUrl.parse("wss://antiprimal.net")
if (client?.relay(searchRelay) == null) {
client?.addRelay(url = searchRelay, capabilities = RelayCapabilities.read())
client?.connectRelay(searchRelay)
}
val kinds = listOf(Kind.fromStd(KindStandard.METADATA))
val filter = Filter().kinds(kinds).search(query).limit(10u)
val target =

View File

@@ -62,8 +62,10 @@ class NostrViewModel(
private val seenPublicKeys = mutableSetOf<PublicKey>()
init {
startMetadataBatchProcessor()
startNotificationHandler()
startMetadataBatchHandler()
getCacheMetadata()
login()
}
override fun onCleared() {
@@ -83,8 +85,35 @@ class NostrViewModel(
}
}
private fun startMetadataBatchProcessor() {
private fun startNotificationHandler() {
viewModelScope.launch {
// Wait until the client is ready
nostr.waitUntilInitialized()
nostr.handleNotifications(
onMetadataUpdate = { pubkey, metadata ->
updateMetadata(pubkey, metadata)
},
onContactListUpdate = { contactList ->
_contactList.value = contactList.toSet()
},
onSubscriptionClose = {
getChatRooms()
},
onNewMessage = { event ->
viewModelScope.launch {
_newEvents.emit(event)
}
},
)
}
}
private fun startMetadataBatchHandler() {
viewModelScope.launch {
// Wait until the client is ready
nostr.waitUntilInitialized()
val batch = mutableSetOf<PublicKey>()
val timeout = 500L // 500ms timeout for batching
@@ -116,15 +145,56 @@ class NostrViewModel(
private fun getCacheMetadata() {
viewModelScope.launch {
// Wait until the client is ready
nostr.waitUntilInitialized()
val results = nostr.getAllCacheMetadata()
results.forEach { (pubkey, metadata) ->
println("Cache metadata for pubkey $pubkey: $metadata")
updateMetadata(pubkey, metadata)
seenPublicKeys.add(pubkey)
}
}
}
private fun login() {
viewModelScope.launch {
// Wait until the client is ready
nostr.waitUntilInitialized()
// Get user's signer secret
val secret = secretStore.get("user_signer")
// If no secret is found, show onboarding screen
when (secret) {
null -> {
_emptySecret.value = true
return@launch
}
else -> _emptySecret.value = false
}
// Handle different signer types
if (secret.startsWith("nsec1")) {
val keys = Keys.parse(secret)
nostr.setSigner(keys)
} else if (secret.startsWith("bunker://")) {
try {
val appKeys = getOrInitAppKeys()
val bunker = NostrConnectUri.parse(secret)
val timeout = Duration.parse("50s") // 50 seconds timeout
val remote =
NostrConnect(uri = bunker, appKeys = appKeys, timeout = timeout, null)
nostr.setSigner(remote)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
} else {
throw IllegalArgumentException("Invalid secret format: $secret")
}
}
}
private fun requestMetadata(pubkey: PublicKey) {
if (seenPublicKeys.add(pubkey)) {
viewModelScope.launch {
@@ -145,82 +215,11 @@ class NostrViewModel(
return flow.asStateFlow()
}
suspend fun login() {
try {
getUserSecret()
} catch (e: Exception) {
showError("Failed to login: ${e.message}")
}
}
fun startNotificationHandler() {
viewModelScope.launch {
nostr.handleNotifications(
onMetadataUpdate = { pubkey, metadata ->
updateMetadata(pubkey, metadata)
},
onContactListUpdate = { contactList ->
_contactList.value = contactList.toSet()
},
onSubscriptionClose = {
getChatRooms()
},
onNewMessage = { event ->
viewModelScope.launch {
_newEvents.emit(event)
}
},
)
}
}
fun currentUser(): PublicKey? {
return nostr.signer.currentUser
}
fun logout() {
viewModelScope.launch {
_emptySecret.value = true
_chatRooms.value = emptySet()
secretStore.clear("user_signer")
nostr.exit()
}
}
suspend fun getUserSecret() {
// Get user's signer secret
val secret = secretStore.get("user_signer")
// If no secret is found, show onboarding screen
when (secret) {
null -> {
_emptySecret.value = true
return
}
else -> _emptySecret.value = false
}
// Handle different signer types
if (secret.startsWith("nsec1")) {
val keys = Keys.parse(secret)
nostr.setSigner(keys)
} else if (secret.startsWith("bunker://")) {
try {
val appKeys = getOrInitAppKeys()
val bunker = NostrConnectUri.parse(secret)
val timeout = Duration.parse("50s") // 50 seconds timeout
val remote = NostrConnect(uri = bunker, appKeys = appKeys, timeout = timeout, null)
nostr.setSigner(remote)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
} else {
throw IllegalArgumentException("Invalid secret format: $secret")
}
}
suspend fun getOrInitAppKeys(): Keys {
private suspend fun getOrInitAppKeys(): Keys {
val secret = secretStore.get("app_keys")
// If app keys are already stored, use them
@@ -348,6 +347,14 @@ class NostrViewModel(
}
}
suspend fun refreshChatRooms() {
try {
_chatRooms.value = nostr.getChatRooms() ?: emptySet()
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
suspend fun getChatRoomMessages(roomId: Long): List<UnsignedEvent> {
try {
return nostr.getChatRoomMessages(roomId)