chore: merge the develop branch into master #1

Merged
reya merged 43 commits from develop into master 2026-05-23 00:50:13 +00:00
4 changed files with 112 additions and 52 deletions
Showing only changes of commit b0eb083284 - Show all commits

View File

@@ -20,7 +20,6 @@ kotlin {
implementation(libs.compose.uiToolingPreview) implementation(libs.compose.uiToolingPreview)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation("androidx.navigation:navigation-compose:2.8.8") 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:1.2.1")
implementation("androidx.datastore:datastore-preferences-core:1.2.1") implementation("androidx.datastore:datastore-preferences-core:1.2.1")
implementation("org.jetbrains.compose.material3:material3:1.11.0-alpha07") implementation("org.jetbrains.compose.material3:material3:1.11.0-alpha07")

View File

@@ -27,6 +27,7 @@ kotlin {
commonMain.dependencies { commonMain.dependencies {
implementation("org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0") 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-coroutines-core:1.10.2")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
implementation("su.reya:nostr-sdk-kmp:0.1.5") implementation("su.reya:nostr-sdk-kmp:0.1.5")
implementation("com.squareup.okio:okio:3.16.2") implementation("com.squareup.okio:okio:3.16.2")
implementation(libs.ktor.client.core) implementation(libs.ktor.client.core)

View File

@@ -73,15 +73,20 @@ class Nostr {
.sleepWhenIdle(SleepWhenIdle.Enabled(idleTimeout)) .sleepWhenIdle(SleepWhenIdle.Enabled(idleTimeout))
.build() .build()
// Bootstrap relays
client?.addRelay(RelayUrl.parse("wss://relay.primal.net")) client?.addRelay(RelayUrl.parse("wss://relay.primal.net"))
client?.addRelay(RelayUrl.parse("wss://user.kindpag.es")) client?.addRelay(RelayUrl.parse("wss://user.kindpag.es"))
// Indexer relay for NIP-65 discovery
client?.addRelay( client?.addRelay(
url = RelayUrl.parse("wss://indexer.coracle.social"), url = RelayUrl.parse("wss://indexer.coracle.social"),
capabilities = RelayCapabilities.gossip() capabilities = RelayCapabilities.gossip()
) )
// Connect to all bootstrap relays and wait for all connections to be established
client?.connect(Duration.parse("10s")) client?.connect(Duration.parse("10s"))
} catch (e: Exception) { } 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 // Fetch metadata for current user
getUserMetadata() getUserMetadata()
} catch (e: Exception) { } 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 // Fetch metadata for current user
getUserMetadata() getUserMetadata()
} catch (e: Exception) { } 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 { return try {
signer?.getPublicKey()?.toBech32() == event.author().toBech32() signer?.getPublicKey()?.toBech32() == event.author().toBech32()
} catch (e: Exception) { } catch (e: Exception) {
println("Failed to check if event is signed by user: ${e.message}")
false false
} }
} }
suspend fun getUserMetadata() { suspend fun getUserMetadata() {
// Get the latest metadata event if (userPubkey == null) return
val metadataFilter =
Filter().author(userPubkey!!).limit(1u).kind(Kind.fromStd(KindStandard.METADATA)) try {
// Get the latest metadata event
val metadataFilter =
Filter().author(userPubkey!!).limit(1u).kind(Kind.fromStd(KindStandard.METADATA))
// Get the latest contact list event // Get the latest contact list event
val contactFilter = val contactFilter =
Filter().author(userPubkey!!).limit(1u).kind(Kind.fromStd(KindStandard.CONTACT_LIST)) Filter().author(userPubkey!!).limit(1u)
.kind(Kind.fromStd(KindStandard.CONTACT_LIST))
// Get the latest messaging relay list event // Get the latest messaging relay list event
val msgRelayFilter = val msgRelayFilter =
Filter().author(userPubkey!!).limit(1u).kind(Kind.fromStd(KindStandard.INBOX_RELAYS)) Filter().author(userPubkey!!).limit(1u)
.kind(Kind.fromStd(KindStandard.INBOX_RELAYS))
// Construct a target that includes all filters // Construct a target that includes all filters
val target = ReqTarget.auto(listOf(metadataFilter, contactFilter, msgRelayFilter)) val target = ReqTarget.auto(listOf(metadataFilter, contactFilter, msgRelayFilter))
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) 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) { suspend fun getUserMessages(msgRelayList: Event) {
val userPubkey = signer?.getPublicKey() ?: return try {
val relays = extractMessagingRelayList(msgRelayList) 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<RelayUrl, List<Filter>>()
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<RelayUrl, List<Filter>>()
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) { suspend fun handleNotifications(onMetadataUpdate: (PublicKey, Metadata) -> Unit) {
val now = Timestamp.now() val now = Timestamp.now()
val processedEvent = mutableSetOf<EventId>() val processedEvent = mutableSetOf<EventId>()
val notifications = client?.notifications() ?: return val notifications = client?.notifications() ?: return
while (true) { while (true) {
val notification = notifications.next() ?: continue val notification = notifications.next() ?: continue
@@ -247,9 +266,9 @@ class Nostr {
return event?.content()?.let { UnsignedEvent.fromJson(it) } return event?.content()?.let { UnsignedEvent.fromJson(it) }
} catch (e: Exception) { } 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) { private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) {
@@ -263,7 +282,7 @@ class Nostr {
client?.database()?.saveEvent(event) client?.database()?.saveEvent(event)
client?.database()?.saveEvent(rumor.signWithKeys(rngKeys)) client?.database()?.saveEvent(rumor.signWithKeys(rngKeys))
} catch (e: Exception) { } catch (e: Exception) {
// TODO: log error println("Failed to set cached rumor: ${e.message}")
} }
} }
@@ -289,7 +308,7 @@ class Nostr {
// Return the rumor // Return the rumor
return rumor return rumor
} catch (e: Exception) { } catch (e: Exception) {
// TODO: log error println("Failed to unwrap gift: ${e.message}")
continue continue
} }
} }
@@ -378,13 +397,13 @@ class Nostr {
} }
suspend fun fetchMetadataBatch(keys: List<PublicKey>) { suspend fun fetchMetadataBatch(keys: List<PublicKey>) {
val filter = val filter = Filter()
Filter() .kind(Kind.fromStd(KindStandard.METADATA))
.kind(Kind.fromStd(KindStandard.METADATA)) .authors(keys)
.authors(keys) .limit(keys.size.toULong())
.limit(keys.size.toULong())
val target = val metadataRelay = RelayUrl.parse("wss://user.kindpag.es")
ReqTarget.manual(mapOf(RelayUrl.parse("wss://user.kindpag.es") to listOf(filter))) val target = ReqTarget.manual(mapOf(metadataRelay to listOf(filter)))
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
client?.subscribe(target = target, id = "metadata-reqs", closeOn = opts) client?.subscribe(target = target, id = "metadata-reqs", closeOn = opts)
@@ -427,7 +446,7 @@ class Nostr {
// Set the room kind based on interaction status // Set the room kind based on interaction status
if (isInteracting || isContact) { if (isInteracting || isContact) {
room.kind(RoomKind.Ongoing) room.setKind(RoomKind.Ongoing)
} }
rooms.add(room) rooms.add(room)
@@ -436,7 +455,7 @@ class Nostr {
return rooms return rooms
} catch (e: Exception) { } catch (e: Exception) {
println("Failed to get chat rooms: ${e.message}") println("Failed to get chat rooms: ${e.message}")
return null
} }
return null
} }
} }

View File

@@ -1,9 +1,13 @@
package su.reya.coop package su.reya.coop
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import rust.nostr.sdk.Event import rust.nostr.sdk.Event
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.TagKind import rust.nostr.sdk.TagKind
import rust.nostr.sdk.Timestamp import rust.nostr.sdk.Timestamp
import kotlin.time.Clock
import kotlin.time.Instant
enum class RoomKind { enum class RoomKind {
Ongoing, Ongoing,
@@ -40,7 +44,7 @@ data class Room(
val subject = rumor.tags().find(TagKind.Subject)?.content() val subject = rumor.tags().find(TagKind.Subject)?.content()
// Collect the author's public key and all public keys from tags // 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<PublicKey> = mutableSetOf() val pubkeys: MutableSet<PublicKey> = mutableSetOf()
pubkeys.add(rumor.author()) pubkeys.add(rumor.author())
pubkeys.addAll(rumor.tags().publicKeys()) 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) 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 { fun Event.roomId(): Long {
@@ -74,3 +90,28 @@ fun Event.roomId(): Long {
return sortedUniqueKeys.hashCode().toLong() 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"
}
}
}