Files
coop-mobile/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt
T

359 lines
13 KiB
Kotlin

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 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)
}
}
}