chore: merge the develop branch into master #1
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,31 +129,41 @@ 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() {
|
||||||
|
if (userPubkey == null) return
|
||||||
|
|
||||||
|
try {
|
||||||
// Get the latest metadata event
|
// Get the latest metadata event
|
||||||
val metadataFilter =
|
val metadataFilter =
|
||||||
Filter().author(userPubkey!!).limit(1u).kind(Kind.fromStd(KindStandard.METADATA))
|
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) {
|
||||||
|
try {
|
||||||
val userPubkey = signer?.getPublicKey() ?: return
|
val userPubkey = signer?.getPublicKey() ?: return
|
||||||
val relays = extractMessagingRelayList(msgRelayList)
|
val relays = extractMessagingRelayList(msgRelayList)
|
||||||
|
|
||||||
@@ -170,6 +185,10 @@ class Nostr {
|
|||||||
id = "user-messages",
|
id = "user-messages",
|
||||||
closeOn = null
|
closeOn = null
|
||||||
)
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to fetch user messages: ${e.message}", e)
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun handleNotifications(onMetadataUpdate: (PublicKey, Metadata) -> Unit) {
|
suspend fun handleNotifications(onMetadataUpdate: (PublicKey, Metadata) -> Unit) {
|
||||||
@@ -247,10 +266,10 @@ 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) {
|
||||||
if (rumor.id() == null) return
|
if (rumor.id() == null) return
|
||||||
@@ -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 =
|
|
||||||
ReqTarget.manual(mapOf(RelayUrl.parse("wss://user.kindpag.es") to listOf(filter)))
|
val metadataRelay = RelayUrl.parse("wss://user.kindpag.es")
|
||||||
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user