feat: support grouped new chat requests (#23)

Reviewed-on: #23
This commit was merged in pull request #23.
This commit is contained in:
2026-06-18 00:34:23 +00:00
parent ea90a43909
commit 91e4e3b43d
9 changed files with 351 additions and 51 deletions

View File

@@ -33,7 +33,7 @@ kotlin {
implementation(libs.androidx.lifecycle.runtimeCompose)
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
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")
}
androidMain.dependencies {

View File

@@ -50,11 +50,11 @@ import rust.nostr.sdk.SubscribeAutoCloseOptions
import rust.nostr.sdk.Tag
import rust.nostr.sdk.Timestamp
import rust.nostr.sdk.UnsignedEvent
import rust.nostr.sdk.UnwrappedGift
import rust.nostr.sdk.extractRelayList
import rust.nostr.sdk.initLogger
import rust.nostr.sdk.nip17ExtractRelayList
import rust.nostr.sdk.nip59MakeGiftWrapAsync
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
@@ -78,8 +78,6 @@ class Nostr {
private set
var signer: UniversalSigner = UniversalSigner(Keys.generate())
private set
var deviceSigner: AsyncNostrSigner? = null
private set
var sentEvents: MutableMap<EventId, List<RelayUrl>> = mutableMapOf()
private set
var rumorMap: MutableMap<EventId, EventId> = mutableMapOf()
@@ -310,26 +308,22 @@ class Nostr {
}
if (event.kind().asStd()?.equals(KindStandard.GIFT_WRAP) == true) {
try {
val rumor = extractRumor(event)
val rumor = extractRumor(event)
// Logic to notify UI after processing
// Cancel previous tracker if it exists
eoseTrackerJob?.cancel()
// Start a new tracker
eoseTrackerJob = launch {
delay(10000.milliseconds) // Wait for 10 seconds
onSubscriptionClose()
}
// Logic to notify UI after processing
// Cancel previous tracker if it exists
eoseTrackerJob?.cancel()
// Start a new tracker
eoseTrackerJob = launch {
delay(10000.milliseconds) // Wait for 10 seconds
onSubscriptionClose()
}
// Handle new message
rumor?.createdAt()?.asSecs()?.let {
if (it >= now.asSecs()) {
onNewMessage(rumor)
}
// Handle new message
rumor?.createdAt()?.asSecs()?.let {
if (it >= now.asSecs()) {
onNewMessage(rumor)
}
} catch (e: Exception) {
println("Failed to extract rumor: $e")
}
}
}
@@ -372,7 +366,7 @@ class Nostr {
val event = client?.database()?.query(filter)?.first()
return event?.content()?.let { UnsignedEvent.fromJson(it) }
} catch (e: Exception) {
} catch (e: Throwable) {
throw IllegalStateException("Failed to get cached rumor: ${e.message}", e)
}
}
@@ -392,7 +386,7 @@ class Nostr {
)
// Set event kind
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA);
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
// Construct event
val event = EventBuilder(kind, rumor.asJson())
@@ -400,36 +394,64 @@ class Nostr {
.finalizeAsync(Keys.generate())
client?.database()?.saveEvent(event)
} catch (e: Exception) {
} catch (e: Throwable) {
println("Failed to set cached rumor: ${e.message}")
}
}
private 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
// Unwrap the gift with current signer
val gift = UnwrappedGift.fromGiftWrapAsync(signer = signer, giftWrap = event)
val rumor = gift.rumor()
// Decrypt the gift wrap event
val seal = signer.nip44DecryptAsync(event.author(), event.content())
val sealEvent = Event.fromJson(seal)
// Save the rumor to the database
setCachedRumor(event.id(), rumor)
// Verify seal event
if (!sealEvent.verify()) {
println("Failed to verify seal event.")
return null
}
// Return the rumor
return rumor
} catch (e: Exception) {
println("Failed to unwrap gift: ${e.message}")
// Decrypt the rumor
val rumor = signer.nip44DecryptAsync(sealEvent.author(), sealEvent.content())
val unsignedEvent = UnsignedEvent.fromJson(rumor)
// 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> {
// 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.primal.net") to RelayMetadata.READ,
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?) {
// Send relay list event
val relayList = getDefaultRelayList()
val relayListEvent = EventBuilder.relayList(relayList).finalizeAsync(keys);
val relayListEvent = EventBuilder.relayList(relayList).finalizeAsync(keys)
client?.sendEvent(
event = relayListEvent,
@@ -546,7 +568,7 @@ class Nostr {
private suspend fun getLatestMetadata(pubkey: PublicKey): Metadata? {
return try {
val kind = Kind.fromStd(KindStandard.METADATA);
val kind = Kind.fromStd(KindStandard.METADATA)
val filter = Filter().kind(kind).author(pubkey).limit(1u)
val event = client?.database()?.query(filter)?.first() ?: return null
@@ -581,7 +603,7 @@ class Nostr {
suspend fun fetchMetadataBatch(keys: List<PublicKey>) {
try {
val limit = keys.size.toULong() * 4u;
val limit = keys.size.toULong() * 4u
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
// Construct a filter for metadata events
@@ -615,7 +637,7 @@ class Nostr {
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 target = ReqTarget.auto(listOf(filter))
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
@@ -722,7 +744,7 @@ class Nostr {
val filter = Filter().kind(kind).author(userPubkey).pubkeys(pubkeys)
// Determine if it's an ongoing room
val isOngoing = client?.database()?.query(filter)?.isEmpty() == false
val isOngoing = client?.database()?.query(filter)?.isEmpty() ?: false
// Append room to map
roomsMap[newRoom.id] =
@@ -781,7 +803,7 @@ class Nostr {
suspend fun connectMsgRelays(event: Event) {
try {
val urls = nip17ExtractRelayList(event);
val urls = nip17ExtractRelayList(event)
for (url in urls) {
client?.addRelay(url, RelayCapabilities.gossip())
client?.connectRelay(url)