feat: support grouped new chat requests #23

Merged
reya merged 4 commits from feat/request-screen into master 2026-06-18 00:34:24 +00:00
3 changed files with 64 additions and 42 deletions
Showing only changes of commit f939900891 - Show all commits

View File

@@ -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")

View File

@@ -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 {

View File

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