feat: support grouped new chat requests #23
@@ -24,7 +24,7 @@ kotlin {
|
|||||||
implementation(libs.jetbrains.navigation3.ui)
|
implementation(libs.jetbrains.navigation3.ui)
|
||||||
implementation(libs.jetbrains.lifecycle.viewmodelNavigation3)
|
implementation(libs.jetbrains.lifecycle.viewmodelNavigation3)
|
||||||
implementation(libs.androidx.core.splashscreen)
|
implementation(libs.androidx.core.splashscreen)
|
||||||
implementation("su.reya:nostr-sdk-kmp:0.2.7")
|
implementation("su.reya:nostr-sdk-kmp:0.3")
|
||||||
implementation("io.coil-kt.coil3:coil-compose:3.4.0")
|
implementation("io.coil-kt.coil3:coil-compose:3.4.0")
|
||||||
implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0")
|
implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0")
|
||||||
implementation("io.github.kalinjul.easyqrscan:scanner:0.7.0")
|
implementation("io.github.kalinjul.easyqrscan:scanner:0.7.0")
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ kotlin {
|
|||||||
implementation(libs.androidx.lifecycle.runtimeCompose)
|
implementation(libs.androidx.lifecycle.runtimeCompose)
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
|
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
|
||||||
implementation("su.reya:nostr-sdk-kmp:0.2.7")
|
implementation("su.reya:nostr-sdk-kmp:0.3")
|
||||||
implementation("com.squareup.okio:okio:3.16.2")
|
implementation("com.squareup.okio:okio:3.16.2")
|
||||||
}
|
}
|
||||||
androidMain.dependencies {
|
androidMain.dependencies {
|
||||||
|
|||||||
@@ -50,11 +50,11 @@ import rust.nostr.sdk.SubscribeAutoCloseOptions
|
|||||||
import rust.nostr.sdk.Tag
|
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 rust.nostr.sdk.UnwrappedGift
|
|
||||||
import rust.nostr.sdk.extractRelayList
|
import rust.nostr.sdk.extractRelayList
|
||||||
import rust.nostr.sdk.initLogger
|
import rust.nostr.sdk.initLogger
|
||||||
import rust.nostr.sdk.nip17ExtractRelayList
|
import rust.nostr.sdk.nip17ExtractRelayList
|
||||||
import rust.nostr.sdk.nip59MakeGiftWrapAsync
|
import rust.nostr.sdk.nip59MakeGiftWrapAsync
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
@@ -78,8 +78,6 @@ class Nostr {
|
|||||||
private set
|
private set
|
||||||
var signer: UniversalSigner = UniversalSigner(Keys.generate())
|
var signer: UniversalSigner = UniversalSigner(Keys.generate())
|
||||||
private set
|
private set
|
||||||
var deviceSigner: AsyncNostrSigner? = null
|
|
||||||
private set
|
|
||||||
var sentEvents: MutableMap<EventId, List<RelayUrl>> = mutableMapOf()
|
var sentEvents: MutableMap<EventId, List<RelayUrl>> = mutableMapOf()
|
||||||
private set
|
private set
|
||||||
var rumorMap: MutableMap<EventId, EventId> = mutableMapOf()
|
var rumorMap: MutableMap<EventId, EventId> = mutableMapOf()
|
||||||
@@ -310,26 +308,22 @@ class Nostr {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.kind().asStd()?.equals(KindStandard.GIFT_WRAP) == true) {
|
if (event.kind().asStd()?.equals(KindStandard.GIFT_WRAP) == true) {
|
||||||
try {
|
val rumor = extractRumor(event)
|
||||||
val rumor = extractRumor(event)
|
|
||||||
|
|
||||||
// Logic to notify UI after processing
|
// Logic to notify UI after processing
|
||||||
// Cancel previous tracker if it exists
|
// Cancel previous tracker if it exists
|
||||||
eoseTrackerJob?.cancel()
|
eoseTrackerJob?.cancel()
|
||||||
// Start a new tracker
|
// Start a new tracker
|
||||||
eoseTrackerJob = launch {
|
eoseTrackerJob = launch {
|
||||||
delay(10000.milliseconds) // Wait for 10 seconds
|
delay(10000.milliseconds) // Wait for 10 seconds
|
||||||
onSubscriptionClose()
|
onSubscriptionClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle new message
|
// Handle new message
|
||||||
rumor?.createdAt()?.asSecs()?.let {
|
rumor?.createdAt()?.asSecs()?.let {
|
||||||
if (it >= now.asSecs()) {
|
if (it >= now.asSecs()) {
|
||||||
onNewMessage(rumor)
|
onNewMessage(rumor)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
|
||||||
println("Failed to extract rumor: $e")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -372,7 +366,7 @@ class Nostr {
|
|||||||
val event = client?.database()?.query(filter)?.first()
|
val event = client?.database()?.query(filter)?.first()
|
||||||
|
|
||||||
return event?.content()?.let { UnsignedEvent.fromJson(it) }
|
return event?.content()?.let { UnsignedEvent.fromJson(it) }
|
||||||
} catch (e: Exception) {
|
} catch (e: Throwable) {
|
||||||
throw IllegalStateException("Failed to get cached rumor: ${e.message}", e)
|
throw IllegalStateException("Failed to get cached rumor: ${e.message}", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -392,7 +386,7 @@ class Nostr {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Set event kind
|
// Set event kind
|
||||||
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA);
|
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
|
||||||
|
|
||||||
// Construct event
|
// Construct event
|
||||||
val event = EventBuilder(kind, rumor.asJson())
|
val event = EventBuilder(kind, rumor.asJson())
|
||||||
@@ -400,36 +394,64 @@ class Nostr {
|
|||||||
.finalizeAsync(Keys.generate())
|
.finalizeAsync(Keys.generate())
|
||||||
|
|
||||||
client?.database()?.saveEvent(event)
|
client?.database()?.saveEvent(event)
|
||||||
} catch (e: Exception) {
|
} catch (e: Throwable) {
|
||||||
println("Failed to set cached rumor: ${e.message}")
|
println("Failed to set cached rumor: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun extractRumor(event: Event): UnsignedEvent? {
|
private suspend fun extractRumor(event: Event): UnsignedEvent? {
|
||||||
try {
|
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
|
// Check if the rumor is already cached
|
||||||
val cachedRumor = getCachedRumor(event.id())
|
val cachedRumor = getCachedRumor(event.id())
|
||||||
if (cachedRumor != null) return cachedRumor
|
if (cachedRumor != null) return cachedRumor
|
||||||
|
|
||||||
// Unwrap the gift with current signer
|
// Decrypt the gift wrap event
|
||||||
val gift = UnwrappedGift.fromGiftWrapAsync(signer = signer, giftWrap = event)
|
val seal = signer.nip44DecryptAsync(event.author(), event.content())
|
||||||
val rumor = gift.rumor()
|
val sealEvent = Event.fromJson(seal)
|
||||||
|
|
||||||
// Save the rumor to the database
|
// Verify seal event
|
||||||
setCachedRumor(event.id(), rumor)
|
if (!sealEvent.verify()) {
|
||||||
|
println("Failed to verify seal event.")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
// Return the rumor
|
// Decrypt the rumor
|
||||||
return rumor
|
val rumor = signer.nip44DecryptAsync(sealEvent.author(), sealEvent.content())
|
||||||
} catch (e: Exception) {
|
val unsignedEvent = UnsignedEvent.fromJson(rumor)
|
||||||
println("Failed to unwrap gift: ${e.message}")
|
|
||||||
|
// 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: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
println("Failed to unwrap gift ${event.id().toHex()}: ${e.message}")
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getDefaultRelayList(): Map<RelayUrl, RelayMetadata> {
|
private suspend fun getDefaultRelayList(): Map<RelayUrl, RelayMetadata> {
|
||||||
// Construct a list of relays
|
// Construct a list of relays
|
||||||
val relayList = mapOf<RelayUrl, RelayMetadata>(
|
val relayList = mapOf(
|
||||||
RelayUrl.parse("wss://relay.damus.io") to RelayMetadata.READ,
|
RelayUrl.parse("wss://relay.damus.io") to RelayMetadata.READ,
|
||||||
RelayUrl.parse("wss://relay.primal.net") to RelayMetadata.READ,
|
RelayUrl.parse("wss://relay.primal.net") to RelayMetadata.READ,
|
||||||
RelayUrl.parse("wss://relay.nostr.net") to RelayMetadata.WRITE,
|
RelayUrl.parse("wss://relay.nostr.net") to RelayMetadata.WRITE,
|
||||||
@@ -471,7 +493,7 @@ class Nostr {
|
|||||||
suspend fun createIdentity(keys: Keys, name: String, bio: String?, picture: String?) {
|
suspend fun createIdentity(keys: Keys, name: String, bio: String?, picture: String?) {
|
||||||
// Send relay list event
|
// Send relay list event
|
||||||
val relayList = getDefaultRelayList()
|
val relayList = getDefaultRelayList()
|
||||||
val relayListEvent = EventBuilder.relayList(relayList).finalizeAsync(keys);
|
val relayListEvent = EventBuilder.relayList(relayList).finalizeAsync(keys)
|
||||||
|
|
||||||
client?.sendEvent(
|
client?.sendEvent(
|
||||||
event = relayListEvent,
|
event = relayListEvent,
|
||||||
@@ -546,7 +568,7 @@ class Nostr {
|
|||||||
|
|
||||||
private suspend fun getLatestMetadata(pubkey: PublicKey): Metadata? {
|
private suspend fun getLatestMetadata(pubkey: PublicKey): Metadata? {
|
||||||
return try {
|
return try {
|
||||||
val kind = Kind.fromStd(KindStandard.METADATA);
|
val kind = Kind.fromStd(KindStandard.METADATA)
|
||||||
val filter = Filter().kind(kind).author(pubkey).limit(1u)
|
val filter = Filter().kind(kind).author(pubkey).limit(1u)
|
||||||
val event = client?.database()?.query(filter)?.first() ?: return null
|
val event = client?.database()?.query(filter)?.first() ?: return null
|
||||||
|
|
||||||
@@ -581,7 +603,7 @@ class Nostr {
|
|||||||
|
|
||||||
suspend fun fetchMetadataBatch(keys: List<PublicKey>) {
|
suspend fun fetchMetadataBatch(keys: List<PublicKey>) {
|
||||||
try {
|
try {
|
||||||
val limit = keys.size.toULong() * 4u;
|
val limit = keys.size.toULong() * 4u
|
||||||
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
||||||
|
|
||||||
// Construct a filter for metadata events
|
// Construct a filter for metadata events
|
||||||
@@ -615,7 +637,7 @@ class Nostr {
|
|||||||
ackPolicy = AckPolicy.none(),
|
ackPolicy = AckPolicy.none(),
|
||||||
)
|
)
|
||||||
|
|
||||||
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS);
|
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
|
||||||
val filter = Filter().kind(kind).author(signer.currentUser!!).limit(1u)
|
val filter = Filter().kind(kind).author(signer.currentUser!!).limit(1u)
|
||||||
val target = ReqTarget.auto(listOf(filter))
|
val target = ReqTarget.auto(listOf(filter))
|
||||||
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
||||||
@@ -781,7 +803,7 @@ class Nostr {
|
|||||||
|
|
||||||
suspend fun connectMsgRelays(event: Event) {
|
suspend fun connectMsgRelays(event: Event) {
|
||||||
try {
|
try {
|
||||||
val urls = nip17ExtractRelayList(event);
|
val urls = nip17ExtractRelayList(event)
|
||||||
for (url in urls) {
|
for (url in urls) {
|
||||||
client?.addRelay(url, RelayCapabilities.gossip())
|
client?.addRelay(url, RelayCapabilities.gossip())
|
||||||
client?.connectRelay(url)
|
client?.connectRelay(url)
|
||||||
|
|||||||
Reference in New Issue
Block a user