split nostr class
This commit is contained in:
@@ -14,6 +14,7 @@ import androidx.lifecycle.ProcessLifecycleOwner
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import su.reya.coop.coop.storage.SecretStore
|
import su.reya.coop.coop.storage.SecretStore
|
||||||
|
import su.reya.coop.nostr.NostrManager
|
||||||
import kotlin.system.exitProcess
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import kotlinx.coroutines.Job
|
|||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import su.reya.coop.nostr.NostrManager
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
private const val GROUP_KEY_MESSAGES = "su.reya.coop.MESSAGES"
|
private const val GROUP_KEY_MESSAGES = "su.reya.coop.MESSAGES"
|
||||||
@@ -72,10 +73,10 @@ class NostrForegroundService : Service() {
|
|||||||
// Handle notifications
|
// Handle notifications
|
||||||
nostr.handleNotifications(
|
nostr.handleNotifications(
|
||||||
onMetadataUpdate = { pubkey, metadata ->
|
onMetadataUpdate = { pubkey, metadata ->
|
||||||
serviceScope.launch { nostr.emitMetadataUpdate(pubkey, metadata) }
|
serviceScope.launch { nostr.profiles.emitMetadataUpdate(pubkey, metadata) }
|
||||||
},
|
},
|
||||||
onContactListUpdate = { contacts ->
|
onContactListUpdate = { contacts ->
|
||||||
serviceScope.launch { nostr.emitContactListUpdate(contacts) }
|
serviceScope.launch { nostr.profiles.emitContactListUpdate(contacts) }
|
||||||
},
|
},
|
||||||
onNewMessage = { event ->
|
onNewMessage = { event ->
|
||||||
serviceScope.launch {
|
serviceScope.launch {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -41,6 +41,7 @@ import rust.nostr.sdk.Tag
|
|||||||
import rust.nostr.sdk.Timestamp
|
import rust.nostr.sdk.Timestamp
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
import su.reya.coop.blossom.BlossomClient
|
import su.reya.coop.blossom.BlossomClient
|
||||||
|
import su.reya.coop.nostr.Nostr
|
||||||
import su.reya.coop.storage.SecretStorage
|
import su.reya.coop.storage.SecretStorage
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
@@ -87,7 +88,7 @@ class NostrViewModel(
|
|||||||
|
|
||||||
private val seenPublicKeys = mutableSetOf<PublicKey>()
|
private val seenPublicKeys = mutableSetOf<PublicKey>()
|
||||||
|
|
||||||
val isSyncing = nostr.messageSyncState.map { it.isSyncing }
|
val isSyncing = nostr.messages.messageSyncState.map { it.isSyncing }
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -157,7 +158,7 @@ class NostrViewModel(
|
|||||||
private suspend fun runObserver() = coroutineScope {
|
private suspend fun runObserver() = coroutineScope {
|
||||||
// Observe message sync progress
|
// Observe message sync progress
|
||||||
launch {
|
launch {
|
||||||
nostr.messageSyncState.collect { state ->
|
nostr.messages.messageSyncState.collect { state ->
|
||||||
// When at least some messages are processed, allow UI to show the list
|
// When at least some messages are processed, allow UI to show the list
|
||||||
if (state.processedCount > 0) {
|
if (state.processedCount > 0) {
|
||||||
_isPartialProcessedGiftWrap.value = true
|
_isPartialProcessedGiftWrap.value = true
|
||||||
@@ -192,14 +193,14 @@ class NostrViewModel(
|
|||||||
|
|
||||||
// Observe contact list updates
|
// Observe contact list updates
|
||||||
launch {
|
launch {
|
||||||
nostr.contactListUpdates.collect { contacts ->
|
nostr.profiles.contactListUpdates.collect { contacts ->
|
||||||
_contactList.value = contacts.toSet()
|
_contactList.value = contacts.toSet()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Observe metadata updates
|
// Observe metadata updates
|
||||||
launch {
|
launch {
|
||||||
nostr.metadataUpdates.collect { (pubkey, metadata) ->
|
nostr.profiles.metadataUpdates.collect { (pubkey, metadata) ->
|
||||||
updateMetadata(pubkey, metadata)
|
updateMetadata(pubkey, metadata)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -237,7 +238,7 @@ class NostrViewModel(
|
|||||||
val keysToRequest = batch.toList()
|
val keysToRequest = batch.toList()
|
||||||
batch.clear()
|
batch.clear()
|
||||||
|
|
||||||
nostr.fetchMetadataBatch(keysToRequest)
|
nostr.profiles.fetchMetadataBatch(keysToRequest)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -248,7 +249,7 @@ class NostrViewModel(
|
|||||||
// Wait until the client is ready
|
// Wait until the client is ready
|
||||||
nostr.waitUntilInitialized()
|
nostr.waitUntilInitialized()
|
||||||
|
|
||||||
val results = nostr.getAllCacheMetadata()
|
val results = nostr.profiles.getAllCacheMetadata()
|
||||||
results.forEach { (pubkey, metadata) ->
|
results.forEach { (pubkey, metadata) ->
|
||||||
// Update the metadata state
|
// Update the metadata state
|
||||||
updateMetadata(pubkey, metadata)
|
updateMetadata(pubkey, metadata)
|
||||||
@@ -293,20 +294,20 @@ class NostrViewModel(
|
|||||||
|
|
||||||
if (pubkey != null) {
|
if (pubkey != null) {
|
||||||
// Get chat rooms
|
// Get chat rooms
|
||||||
val rooms = nostr.getChatRooms() ?: emptySet()
|
val rooms = nostr.messages.getChatRooms() ?: emptySet()
|
||||||
if (rooms.isNotEmpty()) {
|
if (rooms.isNotEmpty()) {
|
||||||
mergeChatRooms(rooms)
|
mergeChatRooms(rooms)
|
||||||
_isPartialProcessedGiftWrap.value = true
|
_isPartialProcessedGiftWrap.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all metadata for the current user
|
// Get all metadata for the current user
|
||||||
nostr.getUserMetadata()
|
nostr.profiles.getUserMetadata()
|
||||||
|
|
||||||
// Small delay to ensure all relays are connected
|
// Small delay to ensure all relays are connected
|
||||||
delay(2.seconds)
|
delay(2.seconds)
|
||||||
|
|
||||||
// Check if the relay list is empty
|
// Check if the relay list is empty
|
||||||
val relays = nostr.getMsgRelays(pubkey)
|
val relays = nostr.relays.getMsgRelays(pubkey)
|
||||||
if (relays.isEmpty()) _isRelayListEmpty.value = true
|
if (relays.isEmpty()) _isRelayListEmpty.value = true
|
||||||
|
|
||||||
break
|
break
|
||||||
@@ -435,7 +436,7 @@ class NostrViewModel(
|
|||||||
_isBusy.value = true
|
_isBusy.value = true
|
||||||
try {
|
try {
|
||||||
val avatarUrl = picture?.let { blossomUpload(it, contentType ?: "image/jpeg") }
|
val avatarUrl = picture?.let { blossomUpload(it, contentType ?: "image/jpeg") }
|
||||||
val newMetadata = nostr.updateProfile(name, bio, avatarUrl)
|
val newMetadata = nostr.profiles.updateProfile(name, bio, avatarUrl)
|
||||||
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
|
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
|
||||||
// Update the metadata state after successfully published
|
// Update the metadata state after successfully published
|
||||||
updateMetadata(currentUser, newMetadata)
|
updateMetadata(currentUser, newMetadata)
|
||||||
@@ -460,7 +461,7 @@ class NostrViewModel(
|
|||||||
try {
|
try {
|
||||||
val avatarUrl = picture?.let { blossomUpload(it, contentType ?: "image/jpeg") }
|
val avatarUrl = picture?.let { blossomUpload(it, contentType ?: "image/jpeg") }
|
||||||
// Create identity
|
// Create identity
|
||||||
nostr.createIdentity(keys = keys, name = name, bio, picture = avatarUrl)
|
nostr.profiles.createIdentity(keys = keys, name = name, bio, picture = avatarUrl)
|
||||||
// Persist the secret in the secret storage
|
// Persist the secret in the secret storage
|
||||||
secretStore.set("user_signer", secret)
|
secretStore.set("user_signer", secret)
|
||||||
// Update local states
|
// Update local states
|
||||||
@@ -567,14 +568,14 @@ class NostrViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun refetchMsgRelays(pubkey: PublicKey) {
|
suspend fun refetchMsgRelays(pubkey: PublicKey) {
|
||||||
val relays = nostr.fetchMsgRelays(pubkey)
|
val relays = nostr.relays.fetchMsgRelays(pubkey)
|
||||||
if (relays.isNotEmpty()) dismissRelayWarning()
|
if (relays.isNotEmpty()) dismissRelayWarning()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun useDefaultMsgRelayList() {
|
suspend fun useDefaultMsgRelayList() {
|
||||||
try {
|
try {
|
||||||
val defaultRelays = nostr.getDefaultMsgRelayList()
|
val defaultRelays = nostr.relays.getDefaultMsgRelayList()
|
||||||
nostr.setMsgRelays(defaultRelays)
|
nostr.relays.setMsgRelays(defaultRelays)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
showError("Error: ${e.message}")
|
showError("Error: ${e.message}")
|
||||||
}
|
}
|
||||||
@@ -583,7 +584,7 @@ class NostrViewModel(
|
|||||||
suspend fun currentUserRelayList(): Map<RelayUrl, RelayMetadata?> {
|
suspend fun currentUserRelayList(): Map<RelayUrl, RelayMetadata?> {
|
||||||
try {
|
try {
|
||||||
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
|
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
|
||||||
return nostr.getRelayList(currentUser)
|
return nostr.relays.getRelayList(currentUser)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
showError("Error: ${e.message}")
|
showError("Error: ${e.message}")
|
||||||
return emptyMap()
|
return emptyMap()
|
||||||
@@ -596,7 +597,7 @@ class NostrViewModel(
|
|||||||
val relays = currentUserRelayList().toMutableMap()
|
val relays = currentUserRelayList().toMutableMap()
|
||||||
relays[relayUrl] = RelayMetadata.WRITE
|
relays[relayUrl] = RelayMetadata.WRITE
|
||||||
|
|
||||||
nostr.setRelaylist(relays)
|
nostr.relays.setRelaylist(relays)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
showError("Error: ${e.message}")
|
showError("Error: ${e.message}")
|
||||||
}
|
}
|
||||||
@@ -608,7 +609,7 @@ class NostrViewModel(
|
|||||||
val relays = currentUserRelayList().toMutableMap()
|
val relays = currentUserRelayList().toMutableMap()
|
||||||
relays[relayUrl] = RelayMetadata.READ
|
relays[relayUrl] = RelayMetadata.READ
|
||||||
|
|
||||||
nostr.setRelaylist(relays)
|
nostr.relays.setRelaylist(relays)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
showError("Error: ${e.message}")
|
showError("Error: ${e.message}")
|
||||||
}
|
}
|
||||||
@@ -620,7 +621,7 @@ class NostrViewModel(
|
|||||||
val relays = currentUserRelayList().toMutableMap()
|
val relays = currentUserRelayList().toMutableMap()
|
||||||
relays.remove(relayUrl)
|
relays.remove(relayUrl)
|
||||||
|
|
||||||
nostr.setRelaylist(relays)
|
nostr.relays.setRelaylist(relays)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
showError("Error: ${e.message}")
|
showError("Error: ${e.message}")
|
||||||
}
|
}
|
||||||
@@ -629,7 +630,7 @@ class NostrViewModel(
|
|||||||
suspend fun currentUserMsgRelayList(): List<RelayUrl> {
|
suspend fun currentUserMsgRelayList(): List<RelayUrl> {
|
||||||
try {
|
try {
|
||||||
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
|
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
|
||||||
return nostr.getMsgRelays(currentUser)
|
return nostr.relays.getMsgRelays(currentUser)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
showError("Error: ${e.message}")
|
showError("Error: ${e.message}")
|
||||||
return emptyList()
|
return emptyList()
|
||||||
@@ -642,7 +643,7 @@ class NostrViewModel(
|
|||||||
val relays = currentUserMsgRelayList().toMutableSet()
|
val relays = currentUserMsgRelayList().toMutableSet()
|
||||||
relays.add(relayUrl)
|
relays.add(relayUrl)
|
||||||
|
|
||||||
nostr.setMsgRelays(relays.toList())
|
nostr.relays.setMsgRelays(relays.toList())
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
showError("Error: ${e.message}")
|
showError("Error: ${e.message}")
|
||||||
}
|
}
|
||||||
@@ -654,7 +655,7 @@ class NostrViewModel(
|
|||||||
val relays = currentUserMsgRelayList().toMutableSet()
|
val relays = currentUserMsgRelayList().toMutableSet()
|
||||||
relays.remove(relayUrl)
|
relays.remove(relayUrl)
|
||||||
|
|
||||||
nostr.setMsgRelays(relays.toList())
|
nostr.relays.setMsgRelays(relays.toList())
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
showError("Error: ${e.message}")
|
showError("Error: ${e.message}")
|
||||||
}
|
}
|
||||||
@@ -666,7 +667,7 @@ class NostrViewModel(
|
|||||||
try {
|
try {
|
||||||
val updated = contactList.value + publicKey
|
val updated = contactList.value + publicKey
|
||||||
// Publish new event
|
// Publish new event
|
||||||
nostr.setContactList(updated.toList())
|
nostr.profiles.setContactList(updated.toList())
|
||||||
// Optimistic local update
|
// Optimistic local update
|
||||||
_contactList.update { it + publicKey }
|
_contactList.update { it + publicKey }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -677,7 +678,7 @@ class NostrViewModel(
|
|||||||
suspend fun addContact(address: String): Boolean {
|
suspend fun addContact(address: String): Boolean {
|
||||||
val pubkey = try {
|
val pubkey = try {
|
||||||
if (address.contains("@")) {
|
if (address.contains("@")) {
|
||||||
nostr.searchByAddress(address)
|
nostr.profiles.searchByAddress(address)
|
||||||
} else {
|
} else {
|
||||||
PublicKey.parse(address)
|
PublicKey.parse(address)
|
||||||
}
|
}
|
||||||
@@ -699,7 +700,7 @@ class NostrViewModel(
|
|||||||
try {
|
try {
|
||||||
val updated = contactList.value - publicKey
|
val updated = contactList.value - publicKey
|
||||||
// Publish new event
|
// Publish new event
|
||||||
nostr.setContactList(updated.toList())
|
nostr.profiles.setContactList(updated.toList())
|
||||||
// Optimistic local update
|
// Optimistic local update
|
||||||
_contactList.update { it - publicKey }
|
_contactList.update { it - publicKey }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -761,14 +762,14 @@ class NostrViewModel(
|
|||||||
|
|
||||||
fun getChatRooms() {
|
fun getChatRooms() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val rooms = nostr.getChatRooms() ?: emptySet()
|
val rooms = nostr.messages.getChatRooms() ?: emptySet()
|
||||||
mergeChatRooms(rooms)
|
mergeChatRooms(rooms)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun refreshChatRooms() {
|
suspend fun refreshChatRooms() {
|
||||||
try {
|
try {
|
||||||
val rooms = nostr.getChatRooms() ?: emptySet()
|
val rooms = nostr.messages.getChatRooms() ?: emptySet()
|
||||||
mergeChatRooms(rooms)
|
mergeChatRooms(rooms)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
showError("Error: ${e.message}")
|
showError("Error: ${e.message}")
|
||||||
@@ -777,7 +778,7 @@ class NostrViewModel(
|
|||||||
|
|
||||||
suspend fun getChatRoomMessages(roomId: Long): List<UnsignedEvent> {
|
suspend fun getChatRoomMessages(roomId: Long): List<UnsignedEvent> {
|
||||||
try {
|
try {
|
||||||
return nostr.getChatRoomMessages(roomId)
|
return nostr.messages.getChatRoomMessages(roomId)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
showError("Error: ${e.message}")
|
showError("Error: ${e.message}")
|
||||||
}
|
}
|
||||||
@@ -791,7 +792,7 @@ class NostrViewModel(
|
|||||||
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
|
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
|
||||||
val members = room.members
|
val members = room.members
|
||||||
|
|
||||||
nostr.chatRoomConnect(members.toList())
|
nostr.messages.chatRoomConnect(members.toList())
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
showError("Error: ${e.message}")
|
showError("Error: ${e.message}")
|
||||||
}
|
}
|
||||||
@@ -805,7 +806,7 @@ class NostrViewModel(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
|
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
|
||||||
nostr.sendMessage(
|
nostr.messages.sendMessage(
|
||||||
to = room.members,
|
to = room.members,
|
||||||
content = message,
|
content = message,
|
||||||
subject = room.subject,
|
subject = room.subject,
|
||||||
@@ -822,10 +823,10 @@ class NostrViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun isMessageSent(id: EventId): Boolean {
|
fun isMessageSent(id: EventId): Boolean {
|
||||||
val giftWrapId = nostr.rumorMap[id]
|
val giftWrapId = nostr.messages.rumorMap[id]
|
||||||
|
|
||||||
if (giftWrapId != null) {
|
if (giftWrapId != null) {
|
||||||
val isSent = nostr.sentEvents[giftWrapId]?.isNotEmpty() ?: false
|
val isSent = nostr.messages.sentEvents[giftWrapId]?.isNotEmpty() ?: false
|
||||||
return isSent
|
return isSent
|
||||||
} else {
|
} else {
|
||||||
return false
|
return false
|
||||||
@@ -849,7 +850,7 @@ class NostrViewModel(
|
|||||||
|
|
||||||
suspend fun searchByAddress(query: String): PublicKey? {
|
suspend fun searchByAddress(query: String): PublicKey? {
|
||||||
try {
|
try {
|
||||||
return nostr.searchByAddress(query)
|
return nostr.profiles.searchByAddress(query)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
showError("Error: ${e.message}")
|
showError("Error: ${e.message}")
|
||||||
}
|
}
|
||||||
@@ -858,7 +859,7 @@ class NostrViewModel(
|
|||||||
|
|
||||||
suspend fun searchByNostr(query: String): List<PublicKey> {
|
suspend fun searchByNostr(query: String): List<PublicKey> {
|
||||||
try {
|
try {
|
||||||
return nostr.searchByNostr(query)
|
return nostr.profiles.searchByNostr(query)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
showError("Error: ${e.message}")
|
showError("Error: ${e.message}")
|
||||||
}
|
}
|
||||||
@@ -867,7 +868,7 @@ class NostrViewModel(
|
|||||||
|
|
||||||
suspend fun verifyActivity(pubkey: PublicKey): Timestamp? {
|
suspend fun verifyActivity(pubkey: PublicKey): Timestamp? {
|
||||||
return try {
|
return try {
|
||||||
nostr.verifyActivity(pubkey)
|
nostr.profiles.verifyActivity(pubkey)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
showError("Error: ${e.message}")
|
showError("Error: ${e.message}")
|
||||||
null
|
null
|
||||||
@@ -876,7 +877,7 @@ class NostrViewModel(
|
|||||||
|
|
||||||
suspend fun verifyContact(pubkey: PublicKey): Boolean {
|
suspend fun verifyContact(pubkey: PublicKey): Boolean {
|
||||||
return try {
|
return try {
|
||||||
nostr.verifyContact(pubkey)
|
nostr.profiles.verifyContact(pubkey)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
showError("Error: ${e.message}")
|
showError("Error: ${e.message}")
|
||||||
false
|
false
|
||||||
@@ -885,7 +886,7 @@ class NostrViewModel(
|
|||||||
|
|
||||||
suspend fun mutualContacts(pubkey: PublicKey): Set<PublicKey> {
|
suspend fun mutualContacts(pubkey: PublicKey): Set<PublicKey> {
|
||||||
return try {
|
return try {
|
||||||
nostr.mutualContacts(pubkey)
|
nostr.profiles.mutualContacts(pubkey)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
showError("Error: ${e.message}")
|
showError("Error: ${e.message}")
|
||||||
setOf()
|
setOf()
|
||||||
|
|||||||
340
shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt
Normal file
340
shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
package su.reya.coop.nostr
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import rust.nostr.sdk.AckPolicy
|
||||||
|
import rust.nostr.sdk.Alphabet
|
||||||
|
import rust.nostr.sdk.Client
|
||||||
|
import rust.nostr.sdk.Event
|
||||||
|
import rust.nostr.sdk.EventBuilder
|
||||||
|
import rust.nostr.sdk.EventId
|
||||||
|
import rust.nostr.sdk.Filter
|
||||||
|
import rust.nostr.sdk.Keys
|
||||||
|
import rust.nostr.sdk.Kind
|
||||||
|
import rust.nostr.sdk.KindStandard
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import rust.nostr.sdk.RelayCapabilities
|
||||||
|
import rust.nostr.sdk.RelayUrl
|
||||||
|
import rust.nostr.sdk.ReqExitPolicy
|
||||||
|
import rust.nostr.sdk.ReqTarget
|
||||||
|
import rust.nostr.sdk.SendEventTarget
|
||||||
|
import rust.nostr.sdk.SingleLetterTag
|
||||||
|
import rust.nostr.sdk.Tag
|
||||||
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
|
import rust.nostr.sdk.nip17ExtractRelayList
|
||||||
|
import rust.nostr.sdk.nip59MakeGiftWrapAsync
|
||||||
|
import su.reya.coop.Room
|
||||||
|
import su.reya.coop.RoomKind
|
||||||
|
import su.reya.coop.UniversalSigner
|
||||||
|
import su.reya.coop.roomId
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
data class MessageSyncState(
|
||||||
|
val processedCount: Int = 0,
|
||||||
|
val isSyncing: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
class MessageManager(private val nostr: Nostr) {
|
||||||
|
private val client: Client? get() = nostr.client
|
||||||
|
private val signer: UniversalSigner get() = nostr.signer
|
||||||
|
|
||||||
|
val sentEvents: MutableMap<EventId, List<RelayUrl>> = mutableMapOf()
|
||||||
|
val rumorMap: MutableMap<EventId, EventId> = mutableMapOf()
|
||||||
|
|
||||||
|
private val _messageSyncState = MutableStateFlow(MessageSyncState())
|
||||||
|
val messageSyncState = _messageSyncState.asStateFlow()
|
||||||
|
|
||||||
|
fun updateSyncState(update: (MessageSyncState) -> MessageSyncState) {
|
||||||
|
_messageSyncState.update(update)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getUserMessages(msgRelayList: Event) {
|
||||||
|
try {
|
||||||
|
val author = signer.currentUser ?: throw IllegalStateException("User not signed in")
|
||||||
|
val relays = nip17ExtractRelayList(msgRelayList)
|
||||||
|
|
||||||
|
// Ensure relay connections
|
||||||
|
relays.forEach { relay ->
|
||||||
|
client?.addRelay(relay)
|
||||||
|
client?.connectRelay(relay)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct a filter for gift wrap events
|
||||||
|
val filter = Filter().kind(Kind.fromStd(KindStandard.GIFT_WRAP)).pubkey(author)
|
||||||
|
val target = mutableMapOf<RelayUrl, List<Filter>>()
|
||||||
|
relays.forEach { relay ->
|
||||||
|
target[relay] = listOf(filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
client?.subscribe(
|
||||||
|
target = ReqTarget.manual(target),
|
||||||
|
id = "gift-wraps"
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to fetch user messages: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun extractRumor(event: Event): UnsignedEvent? {
|
||||||
|
try {
|
||||||
|
// Gift wrap must have at least one 'p' tag
|
||||||
|
if (event.tags().publicKeys().isEmpty()) {
|
||||||
|
println("No recipient tags found.")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event must be a gift wrap
|
||||||
|
if (event.kind().asStd().let { it != KindStandard.GIFT_WRAP }) {
|
||||||
|
println("Event is not a gift wrap.")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the rumor is already cached
|
||||||
|
val cachedRumor = getCachedRumor(event.id())
|
||||||
|
if (cachedRumor != null) return cachedRumor
|
||||||
|
|
||||||
|
// Decrypt the gift wrap event
|
||||||
|
val seal = signer.nip44DecryptAsync(event.author(), event.content())
|
||||||
|
val sealEvent = Event.fromJson(seal)
|
||||||
|
|
||||||
|
// Verify seal event
|
||||||
|
if (!sealEvent.verify()) {
|
||||||
|
println("Failed to verify seal event.")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt the rumor
|
||||||
|
val rumor = signer.nip44DecryptAsync(sealEvent.author(), sealEvent.content())
|
||||||
|
val unsignedEvent = UnsignedEvent.fromJson(rumor).ensureId()
|
||||||
|
|
||||||
|
// Ensure the rumor author matches the seal
|
||||||
|
if (unsignedEvent.author() != sealEvent.author()) {
|
||||||
|
println("Author mismatch.")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the rumor for later use
|
||||||
|
setCachedRumor(event.id(), unsignedEvent)
|
||||||
|
|
||||||
|
return unsignedEvent
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
println("Failed to unwrap gift ${event.id().toHex()}: ${e.message}")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getCachedRumor(giftId: EventId): UnsignedEvent? {
|
||||||
|
try {
|
||||||
|
val filter = Filter().identifier(giftId.toHex())
|
||||||
|
val event = client?.database()?.query(filter)?.first()
|
||||||
|
|
||||||
|
return event?.content()?.let { UnsignedEvent.fromJson(it) }
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
throw IllegalStateException("Failed to get cached rumor: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) {
|
||||||
|
try {
|
||||||
|
// Construct reference tags
|
||||||
|
val tags = listOf(
|
||||||
|
Tag.identifier(giftId.toHex()),
|
||||||
|
Tag.publicKey(rumor.author()),
|
||||||
|
Tag.custom("r", listOf(rumor.roomId().toString())),
|
||||||
|
Tag.custom("k", listOf("14"))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Set event kind
|
||||||
|
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
|
||||||
|
|
||||||
|
// Construct event
|
||||||
|
val event = EventBuilder(kind, rumor.asJson())
|
||||||
|
.tags(tags)
|
||||||
|
.finalizeAsync(Keys.generate())
|
||||||
|
|
||||||
|
client?.database()?.saveEvent(event)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
println("Failed to set cached rumor: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getChatRooms(): Set<Room>? {
|
||||||
|
try {
|
||||||
|
val userPubkey =
|
||||||
|
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||||
|
|
||||||
|
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
|
||||||
|
val kTag = SingleLetterTag.lowercase(Alphabet.K)
|
||||||
|
|
||||||
|
// Get all DM events
|
||||||
|
val filter = Filter().kind(kind).customTags(kTag, listOf("14", "dm"))
|
||||||
|
val events = client?.database()?.query(filter)
|
||||||
|
|
||||||
|
// Collect rooms
|
||||||
|
val roomsMap: MutableMap<Long, Room> = mutableMapOf()
|
||||||
|
|
||||||
|
events
|
||||||
|
?.toVec()
|
||||||
|
?.map { UnsignedEvent.fromJson(it.content()) }
|
||||||
|
?.filter { it.tags().publicKeys().isNotEmpty() }
|
||||||
|
?.forEach { event ->
|
||||||
|
val newRoom = Room.new(rumor = event, userPubkey = userPubkey)
|
||||||
|
val existingRoom = roomsMap[newRoom.id]
|
||||||
|
|
||||||
|
// Check if the room already exists
|
||||||
|
if (existingRoom == null || newRoom.createdAt.asSecs() > existingRoom.createdAt.asSecs()) {
|
||||||
|
val rTag = SingleLetterTag.lowercase(Alphabet.R)
|
||||||
|
val filter = Filter().kind(kind).pubkey(userPubkey)
|
||||||
|
.customTag(rTag, newRoom.id.toString())
|
||||||
|
|
||||||
|
// Determine if it's an ongoing room
|
||||||
|
val isOngoing =
|
||||||
|
client?.database()?.query(filter)?.toVec()?.isNotEmpty() ?: false
|
||||||
|
|
||||||
|
// Append room to map
|
||||||
|
roomsMap[newRoom.id] =
|
||||||
|
if (isOngoing) newRoom.copy(kind = RoomKind.Ongoing) else newRoom
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return roomsMap.values.sortedByDescending { it.createdAt.asSecs() }.toSet()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Failed to get chat rooms: ${e.message}")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getChatRoomMessages(roomId: Long): List<UnsignedEvent> {
|
||||||
|
try {
|
||||||
|
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
|
||||||
|
val filter = Filter().kind(kind).reference(roomId.toString())
|
||||||
|
val events = client?.database()?.query(filter)
|
||||||
|
|
||||||
|
// Merge the events
|
||||||
|
return events
|
||||||
|
?.toVec()
|
||||||
|
?.map { UnsignedEvent.fromJson(it.content()).ensureId() }
|
||||||
|
// Filter out events without public keys (receivers)
|
||||||
|
?.filter { it.tags().publicKeys().isNotEmpty() }
|
||||||
|
?.sortedByDescending { it.createdAt().asSecs() } ?: emptyList()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to get chat room messages: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun chatRoomConnect(members: List<PublicKey>) {
|
||||||
|
try {
|
||||||
|
members.forEach { member ->
|
||||||
|
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
|
||||||
|
val filter = Filter().kind(kind).author(member).limit(1u)
|
||||||
|
|
||||||
|
val stream = client?.streamEvents(
|
||||||
|
target = ReqTarget.auto(listOf(filter)),
|
||||||
|
id = null,
|
||||||
|
timeout = Duration.parse("3s"),
|
||||||
|
policy = ReqExitPolicy.ExitOnEose
|
||||||
|
)
|
||||||
|
|
||||||
|
stream?.next()?.let { res ->
|
||||||
|
val event = res.event ?: return@let
|
||||||
|
connectMsgRelays(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to fetch relays: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun connectMsgRelays(event: Event) {
|
||||||
|
try {
|
||||||
|
val urls = nip17ExtractRelayList(event)
|
||||||
|
for (url in urls) {
|
||||||
|
client?.addRelay(url, RelayCapabilities.gossip())
|
||||||
|
client?.connectRelay(url)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to connect to relays: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun sendMessage(
|
||||||
|
to: Set<PublicKey>,
|
||||||
|
content: String,
|
||||||
|
subject: String? = null,
|
||||||
|
replies: List<EventId> = emptyList(),
|
||||||
|
onRumorCreated: ((UnsignedEvent) -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
val currentUser =
|
||||||
|
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||||
|
|
||||||
|
val tags = mutableListOf<Tag>()
|
||||||
|
|
||||||
|
// Add a subject tag if provided
|
||||||
|
if (subject != null) {
|
||||||
|
tags.add(Tag.custom("subject", listOf(subject)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add event tags for replies
|
||||||
|
if (replies.isNotEmpty()) {
|
||||||
|
replies.forEach { replyId ->
|
||||||
|
tags.add(Tag.event(replyId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add public key tags for each recipient
|
||||||
|
to.forEach { pubkey ->
|
||||||
|
tags.add(Tag.publicKey(pubkey))
|
||||||
|
}
|
||||||
|
|
||||||
|
for (receiver in setOf(currentUser) + to) {
|
||||||
|
// Construct the rumor event
|
||||||
|
// NEVER SIGN this event with the current user signer
|
||||||
|
val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), content)
|
||||||
|
.tags(tags)
|
||||||
|
.finalizeUnsigned(currentUser)
|
||||||
|
.ensureId()
|
||||||
|
|
||||||
|
// Emit the rumor to the chat screen
|
||||||
|
if (receiver == currentUser) {
|
||||||
|
onRumorCreated?.invoke(rumor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct the gift wrap event
|
||||||
|
val gift = nip59MakeGiftWrapAsync(
|
||||||
|
signer = signer,
|
||||||
|
receiverPubkey = receiver,
|
||||||
|
rumor = rumor,
|
||||||
|
extraTags = listOf(
|
||||||
|
Tag.custom("k", listOf("14"))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Send the event to receiver's NIP-17 relays
|
||||||
|
val output = client?.sendEvent(
|
||||||
|
event = gift,
|
||||||
|
target = SendEventTarget.toNip17(),
|
||||||
|
ackPolicy = AckPolicy.none(),
|
||||||
|
authenticationTimeout = Duration.parse("2s")
|
||||||
|
)
|
||||||
|
|
||||||
|
if (output != null) {
|
||||||
|
// Keep track of sent events
|
||||||
|
sentEvents[output.id] = emptyList()
|
||||||
|
|
||||||
|
// Keep track of rumor IDs
|
||||||
|
val id = rumor.id() ?: throw IllegalStateException("Rumor ID is null")
|
||||||
|
rumorMap[id] = output.id
|
||||||
|
|
||||||
|
// Collect failed outputs
|
||||||
|
output.failed.forEach { (relayUrl, reason) ->
|
||||||
|
println("Failed to send event to relay $relayUrl: $reason")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to send message: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
266
shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt
Normal file
266
shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
package su.reya.coop.nostr
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.supervisorScope
|
||||||
|
import rust.nostr.sdk.AsyncNostrSigner
|
||||||
|
import rust.nostr.sdk.Client
|
||||||
|
import rust.nostr.sdk.ClientBuilder
|
||||||
|
import rust.nostr.sdk.ClientNotification
|
||||||
|
import rust.nostr.sdk.Event
|
||||||
|
import rust.nostr.sdk.EventId
|
||||||
|
import rust.nostr.sdk.GossipConfig
|
||||||
|
import rust.nostr.sdk.Keys
|
||||||
|
import rust.nostr.sdk.KindStandard
|
||||||
|
import rust.nostr.sdk.LogLevel
|
||||||
|
import rust.nostr.sdk.Metadata
|
||||||
|
import rust.nostr.sdk.NostrDatabase
|
||||||
|
import rust.nostr.sdk.NostrGossip
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import rust.nostr.sdk.RelayMessageEnum
|
||||||
|
import rust.nostr.sdk.SignerAuthenticator
|
||||||
|
import rust.nostr.sdk.SleepWhenIdle
|
||||||
|
import rust.nostr.sdk.Timestamp
|
||||||
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
|
import rust.nostr.sdk.initLogger
|
||||||
|
import su.reya.coop.UniversalSigner
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
object NostrManager {
|
||||||
|
val instance = Nostr()
|
||||||
|
|
||||||
|
val BOOTSTRAP_RELAYS = listOf(
|
||||||
|
"wss://relay.primal.net",
|
||||||
|
"wss://relay.ditto.pub",
|
||||||
|
"wss://user.kindpag.es",
|
||||||
|
)
|
||||||
|
|
||||||
|
val INDEXER_RELAY = listOf(
|
||||||
|
"wss://indexer.coracle.social",
|
||||||
|
)
|
||||||
|
|
||||||
|
val ALL_RELAYS = BOOTSTRAP_RELAYS + INDEXER_RELAY
|
||||||
|
}
|
||||||
|
|
||||||
|
class Nostr {
|
||||||
|
var client: Client? = null
|
||||||
|
private set
|
||||||
|
var signer: UniversalSigner = UniversalSigner(Keys.generate())
|
||||||
|
private set
|
||||||
|
|
||||||
|
val messages = MessageManager(this)
|
||||||
|
val profiles = ProfileManager(this)
|
||||||
|
val relays = RelayManager(this)
|
||||||
|
|
||||||
|
private val isInitialized = MutableStateFlow(false)
|
||||||
|
|
||||||
|
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
|
||||||
|
val newEvents = _newEvents.asSharedFlow()
|
||||||
|
|
||||||
|
suspend fun emitNewEvent(event: UnsignedEvent) {
|
||||||
|
_newEvents.emit(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun init(
|
||||||
|
dbPath: String,
|
||||||
|
logLevel: LogLevel = LogLevel.WARN
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
if (isInitialized.value) return
|
||||||
|
|
||||||
|
// Initialize the logger for nostr client
|
||||||
|
initLogger(logLevel)
|
||||||
|
|
||||||
|
// Initialize configurations for nostr client
|
||||||
|
val lmdb = NostrDatabase.lmdb(dbPath)
|
||||||
|
val gossip = NostrGossip.inMemory()
|
||||||
|
val authenticator = SignerAuthenticator(signer)
|
||||||
|
val idleTimeout = Duration.parse("5m")
|
||||||
|
|
||||||
|
client =
|
||||||
|
ClientBuilder()
|
||||||
|
.authenticator(authenticator)
|
||||||
|
.database(lmdb)
|
||||||
|
.gossip(gossip)
|
||||||
|
.gossipConfig(
|
||||||
|
GossipConfig()
|
||||||
|
.noBackgroundRefresh()
|
||||||
|
.fetchTimeout(Duration.parse("2s"))
|
||||||
|
)
|
||||||
|
.verifySubscriptions(false)
|
||||||
|
.sleepWhenIdle(SleepWhenIdle.Enabled(idleTimeout))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
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() {
|
||||||
|
relays.connectBootstrapRelays()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun reconnect() {
|
||||||
|
relays.reconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun disconnect() {
|
||||||
|
relays.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun prune() {
|
||||||
|
try {
|
||||||
|
client?.database()?.wipe()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to prune database: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setSigner(new: AsyncNostrSigner) {
|
||||||
|
try {
|
||||||
|
signer.switch(new)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to set signer: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isSignedByUser(event: Event): Boolean {
|
||||||
|
return try {
|
||||||
|
signer.currentUser == event.author()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Failed to check if event is signed by user: ${e.message}")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
suspend fun handleNotifications(
|
||||||
|
onMetadataUpdate: (PublicKey, Metadata) -> Unit,
|
||||||
|
onContactListUpdate: (List<PublicKey>) -> Unit,
|
||||||
|
onNewMessage: (UnsignedEvent) -> Unit,
|
||||||
|
) = supervisorScope {
|
||||||
|
val now = Timestamp.now()
|
||||||
|
val processedEvent = mutableSetOf<EventId>()
|
||||||
|
val notifications = client?.notifications() ?: return@supervisorScope
|
||||||
|
|
||||||
|
val giftWrapQueue = Channel<Event>(Channel.UNLIMITED)
|
||||||
|
var processedCount = 0
|
||||||
|
var eoseReceived = false
|
||||||
|
|
||||||
|
launch(Dispatchers.Default) {
|
||||||
|
for (event in giftWrapQueue) {
|
||||||
|
val rumor = messages.extractRumor(event)
|
||||||
|
processedCount++
|
||||||
|
|
||||||
|
// Trigger new message notification
|
||||||
|
if (rumor != null) {
|
||||||
|
if (rumor.createdAt().asSecs() >= now.asSecs()) {
|
||||||
|
onNewMessage(rumor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update sync state
|
||||||
|
messages.updateSyncState {
|
||||||
|
it.copy(
|
||||||
|
processedCount = processedCount,
|
||||||
|
isSyncing = !eoseReceived || !giftWrapQueue.isEmpty
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
val notification = notifications.next() ?: continue
|
||||||
|
|
||||||
|
when (notification) {
|
||||||
|
is ClientNotification.Message -> {
|
||||||
|
val relayUrl = notification.relayUrl
|
||||||
|
|
||||||
|
when (val message = notification.message.asEnum()) {
|
||||||
|
is RelayMessageEnum.EventMsg -> {
|
||||||
|
val event = message.event
|
||||||
|
|
||||||
|
// Prevent processing duplicate events
|
||||||
|
if (processedEvent.contains(event.id())) continue
|
||||||
|
processedEvent.add(event.id())
|
||||||
|
|
||||||
|
when (event.kind().asStd()) {
|
||||||
|
KindStandard.METADATA -> {
|
||||||
|
try {
|
||||||
|
val metadata = Metadata.fromJson(event.content())
|
||||||
|
onMetadataUpdate(event.author(), metadata)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Failed to parse metadata: $e")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
KindStandard.CONTACT_LIST -> {
|
||||||
|
if (isSignedByUser(event = event)) {
|
||||||
|
val pubkeys = event.tags().publicKeys()
|
||||||
|
// Get mutual contacts
|
||||||
|
profiles.getMutualContacts(pubkeys)
|
||||||
|
// Emit contact list update
|
||||||
|
onContactListUpdate(pubkeys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
KindStandard.INBOX_RELAYS -> {
|
||||||
|
// Get all gift wrap events for the current user
|
||||||
|
if (isSignedByUser(event = event)) {
|
||||||
|
messages.getUserMessages(msgRelayList = event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
KindStandard.GIFT_WRAP -> {
|
||||||
|
giftWrapQueue.send(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is RelayMessageEnum.EndOfStoredEvents -> {
|
||||||
|
if (message.subscriptionId == "gift-wraps") {
|
||||||
|
eoseReceived = true
|
||||||
|
if (giftWrapQueue.isEmpty) {
|
||||||
|
messages.updateSyncState { it.copy(isSyncing = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is RelayMessageEnum.Ok -> {
|
||||||
|
if (messages.sentEvents.containsKey(message.eventId)) {
|
||||||
|
val currentRelays =
|
||||||
|
messages.sentEvents[message.eventId] ?: emptyList()
|
||||||
|
messages.sentEvents[message.eventId] = currentRelays + relayUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
/* Ignore other message types */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is ClientNotification.Shutdown -> {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
/* Ignore other message types */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
package su.reya.coop.nostr
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.call.body
|
||||||
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.client.statement.HttpResponse
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import rust.nostr.sdk.AckPolicy
|
||||||
|
import rust.nostr.sdk.Client
|
||||||
|
import rust.nostr.sdk.Contact
|
||||||
|
import rust.nostr.sdk.EventBuilder
|
||||||
|
import rust.nostr.sdk.Filter
|
||||||
|
import rust.nostr.sdk.Keys
|
||||||
|
import rust.nostr.sdk.Kind
|
||||||
|
import rust.nostr.sdk.KindStandard
|
||||||
|
import rust.nostr.sdk.Metadata
|
||||||
|
import rust.nostr.sdk.MetadataRecord
|
||||||
|
import rust.nostr.sdk.Nip05Address
|
||||||
|
import rust.nostr.sdk.Nip05Profile
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import rust.nostr.sdk.RelayCapabilities
|
||||||
|
import rust.nostr.sdk.RelayUrl
|
||||||
|
import rust.nostr.sdk.ReqExitPolicy
|
||||||
|
import rust.nostr.sdk.ReqTarget
|
||||||
|
import rust.nostr.sdk.SendEventTarget
|
||||||
|
import rust.nostr.sdk.SubscribeAutoCloseOptions
|
||||||
|
import rust.nostr.sdk.Timestamp
|
||||||
|
import su.reya.coop.UniversalSigner
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
class ProfileManager(private val nostr: Nostr) {
|
||||||
|
private val client: Client? get() = nostr.client
|
||||||
|
private val signer: UniversalSigner get() = nostr.signer
|
||||||
|
|
||||||
|
private val _metadataUpdates =
|
||||||
|
MutableSharedFlow<Pair<PublicKey, Metadata>>(extraBufferCapacity = 100)
|
||||||
|
val metadataUpdates = _metadataUpdates.asSharedFlow()
|
||||||
|
|
||||||
|
private val _contactListUpdates = MutableSharedFlow<List<PublicKey>>(extraBufferCapacity = 100)
|
||||||
|
val contactListUpdates = _contactListUpdates.asSharedFlow()
|
||||||
|
|
||||||
|
suspend fun emitMetadataUpdate(pubkey: PublicKey, metadata: Metadata) {
|
||||||
|
_metadataUpdates.emit(pubkey to metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun emitContactListUpdate(contacts: List<PublicKey>) {
|
||||||
|
_contactListUpdates.emit(contacts)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getUserMetadata() {
|
||||||
|
try {
|
||||||
|
val author = signer.currentUser ?: throw IllegalStateException("User not signed in")
|
||||||
|
|
||||||
|
// Get the latest metadata event
|
||||||
|
val metadataFilter =
|
||||||
|
Filter().kind(Kind.fromStd(KindStandard.METADATA)).author(author).limit(1u)
|
||||||
|
|
||||||
|
// Get the latest contact list event
|
||||||
|
val contactFilter =
|
||||||
|
Filter().kind(Kind.fromStd(KindStandard.CONTACT_LIST)).author(author).limit(1u)
|
||||||
|
|
||||||
|
// Get the latest messaging relay list event
|
||||||
|
val msgRelayFilter =
|
||||||
|
Filter().kind(Kind.fromStd(KindStandard.INBOX_RELAYS)).author(author).limit(1u)
|
||||||
|
|
||||||
|
// Construct a target that includes all filters
|
||||||
|
val target = ReqTarget.auto(listOf(metadataFilter, contactFilter, msgRelayFilter))
|
||||||
|
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
||||||
|
|
||||||
|
client?.subscribe(target = target, id = "user-metadata", closeOn = opts)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to fetch user metadata: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getMutualContacts(pubkeys: List<PublicKey>) {
|
||||||
|
try {
|
||||||
|
val kind = Kind.fromStd(KindStandard.CONTACT_LIST)
|
||||||
|
val filter = Filter().kind(kind).authors(pubkeys).limit(pubkeys.size.toULong())
|
||||||
|
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
||||||
|
|
||||||
|
val target = mutableMapOf<RelayUrl, List<Filter>>()
|
||||||
|
NostrManager.BOOTSTRAP_RELAYS.forEach { relay ->
|
||||||
|
target[RelayUrl.parse(relay)] = listOf(filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
client?.subscribe(
|
||||||
|
target = ReqTarget.manual(target),
|
||||||
|
id = "mutual-contacts",
|
||||||
|
closeOn = opts,
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to fetch mutual contacts: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun createIdentity(keys: Keys, name: String, bio: String?, picture: String?) {
|
||||||
|
// Send relay list event
|
||||||
|
val relayList = nostr.relays.getDefaultRelayList()
|
||||||
|
val relayListEvent = EventBuilder.relayList(relayList).finalizeAsync(keys)
|
||||||
|
|
||||||
|
client?.sendEvent(
|
||||||
|
event = relayListEvent,
|
||||||
|
target = SendEventTarget.broadcast(),
|
||||||
|
ackPolicy = AckPolicy.all(),
|
||||||
|
okTimeout = Duration.parse("3s")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Send messaging relay list event
|
||||||
|
val msgRelayList = nostr.relays.getDefaultMsgRelayList()
|
||||||
|
val msgRelayListEvent = EventBuilder.nip17RelayList(msgRelayList).finalizeAsync(keys)
|
||||||
|
|
||||||
|
client?.sendEvent(
|
||||||
|
event = msgRelayListEvent,
|
||||||
|
target = SendEventTarget.toNip65(),
|
||||||
|
ackPolicy = AckPolicy.none()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Send metadata event
|
||||||
|
val metadata =
|
||||||
|
Metadata.fromRecord(MetadataRecord(displayName = name, about = bio, picture = picture))
|
||||||
|
val metadataEvent = EventBuilder.metadata(metadata).finalizeAsync(keys)
|
||||||
|
|
||||||
|
client?.sendEvent(
|
||||||
|
event = metadataEvent,
|
||||||
|
target = SendEventTarget.broadcast(),
|
||||||
|
ackPolicy = AckPolicy.none()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Send contact list event
|
||||||
|
val defaultContact =
|
||||||
|
Contact(PublicKey.parse("npub1j3rz3ndl902lya6ywxvy5c983lxs8mpukqnx4pa4lt5wrykwl5ys7wpw3x"))
|
||||||
|
val contactListEvent = EventBuilder.contactList(listOf(defaultContact)).finalizeAsync(keys)
|
||||||
|
|
||||||
|
client?.sendEvent(
|
||||||
|
event = contactListEvent,
|
||||||
|
target = SendEventTarget.toNip65(),
|
||||||
|
ackPolicy = AckPolicy.none()
|
||||||
|
)
|
||||||
|
|
||||||
|
nostr.setSigner(keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateProfile(
|
||||||
|
name: String? = null,
|
||||||
|
bio: String? = null,
|
||||||
|
picture: String? = null
|
||||||
|
): Metadata {
|
||||||
|
val currentUser = signer.currentUser ?: throw IllegalStateException("User not signed in")
|
||||||
|
|
||||||
|
try {
|
||||||
|
val record = getLatestMetadata(currentUser)?.asRecord() ?: MetadataRecord()
|
||||||
|
val newRecord = record.copy(
|
||||||
|
displayName = name ?: record.displayName,
|
||||||
|
about = bio ?: record.about,
|
||||||
|
picture = picture ?: record.picture
|
||||||
|
)
|
||||||
|
val newMetadata = Metadata.fromRecord(newRecord)
|
||||||
|
val event = EventBuilder.metadata(newMetadata).finalizeAsync(signer)
|
||||||
|
|
||||||
|
client?.sendEvent(
|
||||||
|
event = event,
|
||||||
|
target = SendEventTarget.broadcast(),
|
||||||
|
ackPolicy = AckPolicy.none()
|
||||||
|
)
|
||||||
|
|
||||||
|
return newMetadata
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to update identity: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getLatestMetadata(pubkey: PublicKey): Metadata? {
|
||||||
|
return try {
|
||||||
|
val kind = Kind.fromStd(KindStandard.METADATA)
|
||||||
|
val filter = Filter().kind(kind).author(pubkey).limit(1u)
|
||||||
|
val event = client?.database()?.query(filter)?.first() ?: return null
|
||||||
|
|
||||||
|
Metadata.fromJson(event.content())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Failed to get latest metadata: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getAllCacheMetadata(): Map<PublicKey, Metadata> {
|
||||||
|
try {
|
||||||
|
val filter = Filter().kind(Kind.fromStd(KindStandard.METADATA)).limit(100u)
|
||||||
|
val events = client?.database()?.query(filter)
|
||||||
|
val results = mutableMapOf<PublicKey, Metadata>()
|
||||||
|
|
||||||
|
events?.toVec()?.forEach { event ->
|
||||||
|
try {
|
||||||
|
val metadata = Metadata.fromJson(event.content())
|
||||||
|
results[event.author()] = metadata
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Failed to parse metadata: $e")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Failed to get all cache metadata: ${e.message}")
|
||||||
|
return emptyMap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun fetchMetadataBatch(keys: List<PublicKey>) {
|
||||||
|
try {
|
||||||
|
val limit = keys.size.toULong() * 2u
|
||||||
|
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
||||||
|
|
||||||
|
// Construct a filter for metadata events
|
||||||
|
val filter = Filter()
|
||||||
|
.kind(Kind.fromStd(KindStandard.METADATA))
|
||||||
|
.authors(keys)
|
||||||
|
.limit(limit)
|
||||||
|
|
||||||
|
// Construct request target
|
||||||
|
val target = mutableMapOf<RelayUrl, List<Filter>>()
|
||||||
|
NostrManager.BOOTSTRAP_RELAYS.forEach { relay ->
|
||||||
|
target[RelayUrl.parse(relay)] = listOf(filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
client?.subscribe(target = ReqTarget.manual(target), closeOn = opts)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to fetch metadata batch: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setContactList(contacts: List<PublicKey>) {
|
||||||
|
try {
|
||||||
|
val contactList = contacts.map { Contact(it) }
|
||||||
|
val event = EventBuilder.contactList(contactList).finalizeAsync(signer)
|
||||||
|
|
||||||
|
client?.sendEvent(
|
||||||
|
event = event,
|
||||||
|
target = SendEventTarget.broadcast(),
|
||||||
|
ackPolicy = AckPolicy.none(),
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to set contact list: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun profileFromAddress(client: HttpClient, address: Nip05Address): Nip05Profile {
|
||||||
|
try {
|
||||||
|
val response: HttpResponse = client.get(address.url())
|
||||||
|
val bodyString: String = response.body()
|
||||||
|
|
||||||
|
return Nip05Profile.fromJson(address, bodyString)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to fetch profile from address: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun searchByAddress(query: String): PublicKey {
|
||||||
|
try {
|
||||||
|
val address = Nip05Address.parse(query)
|
||||||
|
val profile = profileFromAddress(HttpClient(), address)
|
||||||
|
|
||||||
|
return profile.publicKey()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to search address: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = ReqTarget.manual(mapOf(searchRelay to listOf(filter)))
|
||||||
|
|
||||||
|
val stream = client?.streamEvents(
|
||||||
|
target = target,
|
||||||
|
id = "search",
|
||||||
|
timeout = Duration.parse("3s"),
|
||||||
|
policy = ReqExitPolicy.ExitOnEose
|
||||||
|
)
|
||||||
|
|
||||||
|
// Collect the results
|
||||||
|
val results = mutableListOf<PublicKey>()
|
||||||
|
|
||||||
|
// Keep searching until the stream is closed or timeout
|
||||||
|
stream?.next()?.let { event ->
|
||||||
|
val event = event.event ?: return@let
|
||||||
|
results.add(event.author())
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to search nostr: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun verifyActivity(pubkey: PublicKey): Timestamp? {
|
||||||
|
try {
|
||||||
|
val filter = Filter().author(pubkey).limit(3u)
|
||||||
|
val target = mutableMapOf<RelayUrl, List<Filter>>()
|
||||||
|
NostrManager.BOOTSTRAP_RELAYS.forEach { relay ->
|
||||||
|
target[RelayUrl.parse(relay)] = listOf(filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
val events = client?.fetchEvents(
|
||||||
|
target = ReqTarget.manual(target),
|
||||||
|
timeout = Duration.parse("3s")
|
||||||
|
)
|
||||||
|
|
||||||
|
return events?.first()?.createdAt()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to get latest activity: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun verifyContact(pubkey: PublicKey): Boolean {
|
||||||
|
try {
|
||||||
|
val currentUser =
|
||||||
|
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||||
|
|
||||||
|
val kind = Kind.fromStd(KindStandard.CONTACT_LIST)
|
||||||
|
val filter = Filter().kind(kind).author(currentUser).limit(1u)
|
||||||
|
|
||||||
|
val events = client?.database()?.query(filter)
|
||||||
|
val pubkeys = events?.first()?.tags()?.publicKeys() ?: listOf()
|
||||||
|
|
||||||
|
return pubkeys.contains(pubkey)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to get mutual contacts: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun mutualContacts(pubkey: PublicKey): Set<PublicKey> {
|
||||||
|
try {
|
||||||
|
val currentUser =
|
||||||
|
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||||
|
|
||||||
|
val kind = Kind.fromStd(KindStandard.CONTACT_LIST)
|
||||||
|
val filter = Filter().kind(kind).pubkey(pubkey).limit(1u)
|
||||||
|
|
||||||
|
val events = client?.database()?.query(filter)
|
||||||
|
val contacts = mutableSetOf<PublicKey>()
|
||||||
|
|
||||||
|
events?.toVec()?.filter { it.author() != currentUser }?.forEach { event ->
|
||||||
|
contacts.add(event.author())
|
||||||
|
}
|
||||||
|
|
||||||
|
return contacts.toSet()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to get mutual contacts: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
183
shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt
Normal file
183
shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
package su.reya.coop.nostr
|
||||||
|
|
||||||
|
import rust.nostr.sdk.AckPolicy
|
||||||
|
import rust.nostr.sdk.Client
|
||||||
|
import rust.nostr.sdk.EventBuilder
|
||||||
|
import rust.nostr.sdk.Filter
|
||||||
|
import rust.nostr.sdk.Kind
|
||||||
|
import rust.nostr.sdk.KindStandard
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import rust.nostr.sdk.RelayCapabilities
|
||||||
|
import rust.nostr.sdk.RelayMetadata
|
||||||
|
import rust.nostr.sdk.RelayStatus
|
||||||
|
import rust.nostr.sdk.RelayUrl
|
||||||
|
import rust.nostr.sdk.ReqExitPolicy
|
||||||
|
import rust.nostr.sdk.ReqTarget
|
||||||
|
import rust.nostr.sdk.SendEventTarget
|
||||||
|
import rust.nostr.sdk.SubscribeAutoCloseOptions
|
||||||
|
import rust.nostr.sdk.extractRelayList
|
||||||
|
import rust.nostr.sdk.nip17ExtractRelayList
|
||||||
|
import su.reya.coop.UniversalSigner
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
class RelayManager(private val nostr: Nostr) {
|
||||||
|
private val client: Client? get() = nostr.client
|
||||||
|
private val signer: UniversalSigner get() = nostr.signer
|
||||||
|
|
||||||
|
suspend fun connectBootstrapRelays() {
|
||||||
|
NostrManager.BOOTSTRAP_RELAYS.forEach { url ->
|
||||||
|
client?.addRelay(RelayUrl.parse(url))
|
||||||
|
}
|
||||||
|
NostrManager.INDEXER_RELAY.forEach { url ->
|
||||||
|
client?.addRelay(
|
||||||
|
url = RelayUrl.parse(url),
|
||||||
|
capabilities = RelayCapabilities.gossip()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Connect to all bootstrap relays
|
||||||
|
client?.connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun reconnect() {
|
||||||
|
NostrManager.ALL_RELAYS.forEach { url ->
|
||||||
|
try {
|
||||||
|
client?.relay(RelayUrl.parse(url)).let { relay ->
|
||||||
|
if (relay != null) {
|
||||||
|
if (relay.status() != RelayStatus.CONNECTED) {
|
||||||
|
relay.connect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Failed to reconnect relay: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun disconnect() {
|
||||||
|
NostrManager.ALL_RELAYS.forEach { url ->
|
||||||
|
try {
|
||||||
|
client?.disconnectRelay(RelayUrl.parse(url))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Failed to disconnect relay: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal suspend fun getDefaultRelayList(): Map<RelayUrl, RelayMetadata> {
|
||||||
|
// Construct a list of relays
|
||||||
|
val relayList = mapOf(
|
||||||
|
RelayUrl.parse("wss://relay.damus.io") to RelayMetadata.READ,
|
||||||
|
RelayUrl.parse("wss://relay.primal.net") to RelayMetadata.READ,
|
||||||
|
RelayUrl.parse("wss://relay.nostr.net") to RelayMetadata.WRITE,
|
||||||
|
RelayUrl.parse("wss://nostr.superfriends.online") to RelayMetadata.WRITE
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ensure all relays are added and connected
|
||||||
|
relayList.forEach { (relay, metadata) ->
|
||||||
|
client?.addRelay(
|
||||||
|
url = relay,
|
||||||
|
capabilities =
|
||||||
|
when (metadata) {
|
||||||
|
RelayMetadata.READ -> RelayCapabilities.read()
|
||||||
|
RelayMetadata.WRITE -> RelayCapabilities.write()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
client?.connectRelay(relay)
|
||||||
|
}
|
||||||
|
|
||||||
|
return relayList
|
||||||
|
}
|
||||||
|
|
||||||
|
internal suspend fun getDefaultMsgRelayList(): List<RelayUrl> {
|
||||||
|
// Construct a list of messaging relays
|
||||||
|
val msgRelayList = listOf(
|
||||||
|
RelayUrl.parse("wss://auth.nostr1.com"),
|
||||||
|
RelayUrl.parse("wss://nip17.com"),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ensure all relays are added and connected
|
||||||
|
msgRelayList.forEach { relay ->
|
||||||
|
client?.addRelay(relay, RelayCapabilities.none())
|
||||||
|
client?.connectRelay(relay)
|
||||||
|
}
|
||||||
|
|
||||||
|
return msgRelayList
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setMsgRelays(urls: List<RelayUrl>) {
|
||||||
|
try {
|
||||||
|
val event = EventBuilder.nip17RelayList(urls).finalizeAsync(signer)
|
||||||
|
|
||||||
|
client?.sendEvent(
|
||||||
|
event = event,
|
||||||
|
target = SendEventTarget.toNip65(),
|
||||||
|
ackPolicy = AckPolicy.none(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val currentUser =
|
||||||
|
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||||
|
|
||||||
|
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
|
||||||
|
val filter = Filter().kind(kind).author(currentUser).limit(1u)
|
||||||
|
|
||||||
|
val target = ReqTarget.auto(listOf(filter))
|
||||||
|
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
||||||
|
|
||||||
|
client?.subscribe(target = target, closeOn = opts)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to set msg relays: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getMsgRelays(publicKey: PublicKey): List<RelayUrl> {
|
||||||
|
try {
|
||||||
|
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
|
||||||
|
val filter = Filter().kind(kind).author(publicKey).limit(1u)
|
||||||
|
val events = client?.database()?.query(filter)
|
||||||
|
|
||||||
|
return nip17ExtractRelayList(events?.toVec()?.firstOrNull() ?: return emptyList())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to get msg relays: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun fetchMsgRelays(publicKey: PublicKey): List<RelayUrl> {
|
||||||
|
try {
|
||||||
|
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
|
||||||
|
val filter = Filter().kind(kind).author(publicKey).limit(1u)
|
||||||
|
val target = ReqTarget.auto(listOf(filter))
|
||||||
|
val events = client?.fetchEvents(target, timeout = Duration.parse("3s"))
|
||||||
|
|
||||||
|
return nip17ExtractRelayList(events?.toVec()?.firstOrNull() ?: return emptyList())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to fetch msg relays: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getRelayList(publicKey: PublicKey): Map<RelayUrl, RelayMetadata?> {
|
||||||
|
try {
|
||||||
|
val kind = Kind.fromStd(KindStandard.RELAY_LIST)
|
||||||
|
val filter = Filter().kind(kind).author(publicKey).limit(1u)
|
||||||
|
val events = client?.database()?.query(filter)
|
||||||
|
|
||||||
|
return extractRelayList(events?.toVec()?.firstOrNull() ?: return emptyMap())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to get relay list: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setRelaylist(relays: Map<RelayUrl, RelayMetadata?>) {
|
||||||
|
try {
|
||||||
|
val event = EventBuilder.relayList(relays).finalizeAsync(signer)
|
||||||
|
|
||||||
|
client?.sendEvent(
|
||||||
|
event = event,
|
||||||
|
target = SendEventTarget.broadcast(),
|
||||||
|
ackPolicy = AckPolicy.none(),
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to set msg relays: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user