From b0eb083284db5530cd715754746b46350f7b47c8 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 10 May 2026 08:08:29 +0700 Subject: [PATCH] update --- composeApp/build.gradle.kts | 1 - shared/build.gradle.kts | 1 + .../commonMain/kotlin/su/reya/coop/Nostr.kt | 117 ++++++++++-------- .../commonMain/kotlin/su/reya/coop/Room.kt | 45 ++++++- 4 files changed, 112 insertions(+), 52 deletions(-) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index ec9e031..c6499bd 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -20,7 +20,6 @@ kotlin { implementation(libs.compose.uiToolingPreview) implementation(libs.androidx.activity.compose) implementation("androidx.navigation:navigation-compose:2.8.8") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0") implementation("androidx.datastore:datastore-preferences:1.2.1") implementation("androidx.datastore:datastore-preferences-core:1.2.1") implementation("org.jetbrains.compose.material3:material3:1.11.0-alpha07") diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index bce9a38..bd719e1 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -27,6 +27,7 @@ kotlin { commonMain.dependencies { implementation("org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0") 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.1.5") implementation("com.squareup.okio:okio:3.16.2") implementation(libs.ktor.client.core) diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index 7a26456..bdb4157 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -73,15 +73,20 @@ class Nostr { .sleepWhenIdle(SleepWhenIdle.Enabled(idleTimeout)) .build() + // Bootstrap relays client?.addRelay(RelayUrl.parse("wss://relay.primal.net")) client?.addRelay(RelayUrl.parse("wss://user.kindpag.es")) + + // Indexer relay for NIP-65 discovery client?.addRelay( url = RelayUrl.parse("wss://indexer.coracle.social"), capabilities = RelayCapabilities.gossip() ) + + // Connect to all bootstrap relays and wait for all connections to be established client?.connect(Duration.parse("10s")) } catch (e: Exception) { - println("Failed to initialize client: ${e.message}") + throw IllegalStateException("Failed to initialize Nostr client: ${e.message}", e) } } @@ -104,7 +109,7 @@ class Nostr { // Fetch metadata for current user getUserMetadata() } catch (e: Exception) { - println("Failed to set signer: ${e.message}") + throw IllegalStateException("Failed to set key signer: ${e.message}", e) } } @@ -116,7 +121,7 @@ class Nostr { // Fetch metadata for current user getUserMetadata() } catch (e: Exception) { - println("Failed to set remote signer: ${e.message}") + throw IllegalStateException("Failed to set remote signer: ${e.message}", e) } } @@ -124,59 +129,73 @@ class Nostr { return try { signer?.getPublicKey()?.toBech32() == event.author().toBech32() } catch (e: Exception) { + println("Failed to check if event is signed by user: ${e.message}") false } } suspend fun getUserMetadata() { - // Get the latest metadata event - val metadataFilter = - Filter().author(userPubkey!!).limit(1u).kind(Kind.fromStd(KindStandard.METADATA)) + if (userPubkey == null) return + + try { + // Get the latest metadata event + val metadataFilter = + Filter().author(userPubkey!!).limit(1u).kind(Kind.fromStd(KindStandard.METADATA)) - // Get the latest contact list event - val contactFilter = - Filter().author(userPubkey!!).limit(1u).kind(Kind.fromStd(KindStandard.CONTACT_LIST)) + // Get the latest contact list event + val contactFilter = + Filter().author(userPubkey!!).limit(1u) + .kind(Kind.fromStd(KindStandard.CONTACT_LIST)) - // Get the latest messaging relay list event - val msgRelayFilter = - Filter().author(userPubkey!!).limit(1u).kind(Kind.fromStd(KindStandard.INBOX_RELAYS)) + // Get the latest messaging relay list event + val msgRelayFilter = + Filter().author(userPubkey!!).limit(1u) + .kind(Kind.fromStd(KindStandard.INBOX_RELAYS)) - // Construct a target that includes all filters - val target = ReqTarget.auto(listOf(metadataFilter, contactFilter, msgRelayFilter)) - val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) + // 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) + client?.subscribe(target = target, id = "user-metadata", closeOn = opts) + } catch (e: Exception) { + throw IllegalStateException("Failed to fetch user metadata: ${e.message}", e) + } } suspend fun getUserMessages(msgRelayList: Event) { - val userPubkey = signer?.getPublicKey() ?: return - val relays = extractMessagingRelayList(msgRelayList) + try { + val userPubkey = signer?.getPublicKey() ?: return + val relays = extractMessagingRelayList(msgRelayList) + + // Ensure relay connections + relays.forEach { relay -> + client?.addRelay(relay, RelayCapabilities.none()) + client?.connectRelay(relay) + } + + // Construct a filter for gift wrap events + val filter = Filter().kind(Kind.fromStd(KindStandard.GIFT_WRAP)).pubkey(userPubkey) + val target = mutableMapOf>() + relays.forEach { relay -> + target[relay] = listOf(filter) + } + + client?.subscribe( + target = ReqTarget.manual(target), + id = "user-messages", + closeOn = null + ) + } catch (e: Exception) { + throw IllegalStateException("Failed to fetch user messages: ${e.message}", e) - // Ensure relay connections - relays.forEach { relay -> - client?.addRelay(relay, RelayCapabilities.none()) - client?.connectRelay(relay) } - - // Construct a filter for gift wrap events - val filter = Filter().kind(Kind.fromStd(KindStandard.GIFT_WRAP)).pubkey(userPubkey) - val target = mutableMapOf>() - relays.forEach { relay -> - target[relay] = listOf(filter) - } - - client?.subscribe( - target = ReqTarget.manual(target), - id = "user-messages", - closeOn = null - ) } suspend fun handleNotifications(onMetadataUpdate: (PublicKey, Metadata) -> Unit) { val now = Timestamp.now() val processedEvent = mutableSetOf() val notifications = client?.notifications() ?: return - + while (true) { val notification = notifications.next() ?: continue @@ -247,9 +266,9 @@ class Nostr { return event?.content()?.let { UnsignedEvent.fromJson(it) } } catch (e: Exception) { - // TODO: log error + println("Failed to get cached rumor: ${e.message}") + return null } - return null } private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) { @@ -263,7 +282,7 @@ class Nostr { client?.database()?.saveEvent(event) client?.database()?.saveEvent(rumor.signWithKeys(rngKeys)) } catch (e: Exception) { - // TODO: log error + println("Failed to set cached rumor: ${e.message}") } } @@ -289,7 +308,7 @@ class Nostr { // Return the rumor return rumor } catch (e: Exception) { - // TODO: log error + println("Failed to unwrap gift: ${e.message}") continue } } @@ -378,13 +397,13 @@ class Nostr { } suspend fun fetchMetadataBatch(keys: List) { - val filter = - Filter() - .kind(Kind.fromStd(KindStandard.METADATA)) - .authors(keys) - .limit(keys.size.toULong()) - val target = - ReqTarget.manual(mapOf(RelayUrl.parse("wss://user.kindpag.es") to listOf(filter))) + val filter = Filter() + .kind(Kind.fromStd(KindStandard.METADATA)) + .authors(keys) + .limit(keys.size.toULong()) + + val metadataRelay = RelayUrl.parse("wss://user.kindpag.es") + val target = ReqTarget.manual(mapOf(metadataRelay to listOf(filter))) val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) client?.subscribe(target = target, id = "metadata-reqs", closeOn = opts) @@ -427,7 +446,7 @@ class Nostr { // Set the room kind based on interaction status if (isInteracting || isContact) { - room.kind(RoomKind.Ongoing) + room.setKind(RoomKind.Ongoing) } rooms.add(room) @@ -436,7 +455,7 @@ class Nostr { return rooms } catch (e: Exception) { println("Failed to get chat rooms: ${e.message}") + return null } - return null } } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt index 86c0f1a..503e5ca 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt @@ -1,9 +1,13 @@ package su.reya.coop +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime import rust.nostr.sdk.Event import rust.nostr.sdk.PublicKey import rust.nostr.sdk.TagKind import rust.nostr.sdk.Timestamp +import kotlin.time.Clock +import kotlin.time.Instant enum class RoomKind { Ongoing, @@ -40,7 +44,7 @@ data class Room( val subject = rumor.tags().find(TagKind.Subject)?.content() // Collect the author's public key and all public keys from tags - // Also remove the user's public key from the list + // Also remove the user's public key from the list, current user is always a member val pubkeys: MutableSet = mutableSetOf() pubkeys.add(rumor.author()) pubkeys.addAll(rumor.tags().publicKeys()) @@ -56,9 +60,21 @@ data class Room( } } - fun kind(kind: RoomKind): Room { + fun setKind(kind: RoomKind): Room { return this.copy(kind = kind) } + + fun setCreatedAt(createdAt: Timestamp): Room { + return this.copy(createdAt = createdAt) + } + + fun setSubject(subject: String?): Room { + return this.copy(subject = subject) + } + + fun isGroup(): Boolean { + return members.size > 1 + } } fun Event.roomId(): Long { @@ -74,3 +90,28 @@ fun Event.roomId(): Long { return sortedUniqueKeys.hashCode().toLong() } + +fun Timestamp.ago(): String { + val SECONDS_IN_MINUTE = 60L + val MINUTES_IN_HOUR = 60L + val HOURS_IN_DAY = 24L + val DAYS_IN_MONTH = 30L + + val inputInstant = Instant.fromEpochSeconds(this.asSecs().toLong()) + val now = Clock.System.now() + val duration = now - inputInstant + + return when { + duration.inWholeSeconds < SECONDS_IN_MINUTE -> "now" + duration.inWholeMinutes < MINUTES_IN_HOUR -> "${duration.inWholeMinutes}m" + duration.inWholeHours < HOURS_IN_DAY -> "${duration.inWholeHours}h" + duration.inWholeDays < DAYS_IN_MONTH -> "${duration.inWholeDays}d" + else -> { + val localDateTime = inputInstant.toLocalDateTime(TimeZone.currentSystemDefault()) + val month = + localDateTime.month.name.take(3).lowercase().replaceFirstChar { it.uppercase() } + val day = localDateTime.dayOfMonth.toString().padStart(2, '0') + "$month $day" + } + } +}