3 Commits

Author SHA1 Message Date
5c2115e8b7 chore: bump version 2026-06-04 09:06:00 +07:00
ec337b8756 feat: add self-chat (#13)
Reviewed-on: #13
2026-06-04 01:59:38 +00:00
fcae7d5825 fix: crash when getting all cached metadata (#12)
Reviewed-on: #12
2026-06-03 07:50:34 +00:00
6 changed files with 73 additions and 46 deletions

View File

@@ -69,7 +69,7 @@ android {
minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt()
versionCode = 1
versionName = "0.1.5"
versionName = "0.1.6"
}
packaging {
resources {

View File

@@ -35,9 +35,7 @@ class NostrForegroundService : Service() {
override fun onCreate() {
super.onCreate()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
createNotificationChannel()
}
val notification = createNotification()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {

View File

@@ -1,6 +1,7 @@
package su.reya.coop.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
@@ -47,6 +48,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back
@@ -235,10 +237,15 @@ fun ChatScreen(id: Long) {
.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = "No messages yet",
style = MaterialTheme.typography.titleLargeEmphasized,
style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontWeight = FontWeight.SemiBold
),
color = MaterialTheme.colorScheme.onSurface
)
Text(

View File

@@ -9,25 +9,32 @@ import su.reya.coop.Room
import su.reya.coop.short
fun Room.displayNameFlow(viewModel: NostrViewModel): Flow<String> {
if (!subject.isNullOrBlank()) return flowOf<String>(subject!!)
// Return early if there's a custom subject/room name
subject?.takeIf { it.isNotBlank() }?.let { return flowOf(it) }
val memberFlows = members.map { viewModel.getMetadata(it) }
val displayMembers = if (isGroup()) members.take(2) else members.take(1)
if (displayMembers.isEmpty()) return flowOf("Unknown")
return combine(displayMembers.map { viewModel.getMetadata(it) }) { metadataArray ->
val names = metadataArray.mapIndexed { i, metadata ->
val profile = metadata?.asRecord()
profile?.name?.takeIf { it.isNotBlank() }
?: profile?.displayName?.takeIf { it.isNotBlank() }
?: displayMembers[i].short()
}
return combine(memberFlows) { metadataArray ->
if (isGroup()) {
val profiles = metadataArray.map { it?.asRecord() }
val names = profiles.take(2).mapNotNull { it?.name ?: it?.displayName }
var combined = names.joinToString(", ")
if (profiles.size > 2) combined += ", +${profiles.size - 2}"
combined.ifBlank { "Unknown group" }
val combined = names.joinToString(", ")
val extraCount = members.size - names.size
if (extraCount > 0) "$combined, +$extraCount" else combined
} else {
val profile = metadataArray.firstOrNull()?.asRecord()
profile?.name ?: profile?.displayName ?: members.firstOrNull()?.short() ?: "Unknown"
val name = names.first()
if (displayMembers.first() == viewModel.currentUser()) "$name (you)" else name
}
}
}
fun Room.pictureFlow(viewModel: NostrViewModel): Flow<String?> {
val firstMember = members.firstOrNull() ?: return kotlinx.coroutines.flow.flowOf(null)
val firstMember = members.firstOrNull() ?: return flowOf(null)
return viewModel.getMetadata(firstMember).map { it?.asRecord()?.picture }
}

View File

@@ -55,6 +55,7 @@ import rust.nostr.sdk.giftWrapAsync
import rust.nostr.sdk.initLogger
import rust.nostr.sdk.nip17ExtractRelayList
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
object NostrManager {
val instance = Nostr()
@@ -228,7 +229,7 @@ class Nostr {
client?.subscribe(
target = ReqTarget.manual(target),
id = "all-gift-wraps"
id = "gift-wraps"
)
} catch (e: Exception) {
throw IllegalStateException("Failed to fetch user messages: ${e.message}", e)
@@ -293,7 +294,7 @@ class Nostr {
eoseTrackerJob?.cancel()
// Start a new tracker
eoseTrackerJob = launch {
delay(10000) // Wait for 10 seconds
delay(10000.milliseconds) // Wait for 10 seconds
onSubscriptionClose()
}
@@ -312,7 +313,7 @@ class Nostr {
is RelayMessageEnum.EndOfStoredEvents -> {
val subscriptionId = message.subscriptionId
if (subscriptionId == "all-gift-wraps" || subscriptionId == "newest-gift-wraps") {
if (subscriptionId == "gift-wraps") {
onSubscriptionClose()
}
}
@@ -366,7 +367,7 @@ class Nostr {
Tag.identifier(giftId.toHex()),
Tag.event(rumor.id()!!),
Tag.reference(roomId.toString()),
Tag.custom(TagKind.Unknown("k"), listOf("dm"))
Tag.custom(TagKind.Unknown("k"), listOf("14"))
)
// Set event kind
@@ -395,7 +396,6 @@ class Nostr {
// Try to unwrap the gift with each signer
for (signer in signers) {
try {
// TODO: custom unwrapping logic
val gift = UnwrappedGift.fromGiftWrapAsync(signer = signer, giftWrap = event)
val rumor = gift.rumor()
// Save the rumor to the database
@@ -500,18 +500,23 @@ class Nostr {
suspend fun getAllCacheMetadata(): Map<PublicKey, Metadata> {
try {
val filter = Filter().kind(Kind.fromStd(KindStandard.METADATA)).limit(200u)
val filter = Filter().kind(Kind.fromStd(KindStandard.METADATA)).limit(100u)
val events = client?.database()?.query(filter)
val results = mutableMapOf<PublicKey, Metadata>()
events?.toVec()?.forEach { event ->
try {
val metadata = Metadata.fromJson(event.content())
results[event.author()] = metadata
} catch (e: Exception) {
println("Failed to parse metadata: $e")
}
}
return results
} catch (e: Exception) {
throw IllegalStateException("Failed to get cache metadata: ${e.message}", e)
println("Failed to get all cache metadata: ${e.message}")
return emptyMap()
}
}
@@ -594,7 +599,7 @@ class Nostr {
val kTag = SingleLetterTag.lowercase(Alphabet.K)
// Get all events sent by the user
val filter = Filter().kind(kind).author(userPubkey).customTag(kTag, "14")
val filter = Filter().kind(kind).author(userPubkey).customTags(kTag, listOf("14", "dm"))
val events = client?.database()?.query(filter)
// Collect rooms
@@ -639,6 +644,8 @@ class Nostr {
return events
?.toVec()
?.map { UnsignedEvent.fromJson(it.content()) }
// Filter out events without public keys (receivers)
?.filter { it.tags().publicKeys().isNotEmpty() }
?.sortedByDescending { it.createdAt().asSecs() } ?: emptyList()
} catch (e: Exception) {
throw IllegalStateException("Failed to get chat room messages: ${e.message}", e)
@@ -692,7 +699,7 @@ class Nostr {
}
suspend fun sendMessage(
to: List<PublicKey>,
to: Set<PublicKey>,
content: String,
subject: String? = null,
replies: List<EventId> = emptyList(),
@@ -718,17 +725,16 @@ class Nostr {
// Add public key tags for each recipient
to.forEach { pubkey ->
if (pubkey != currentUser) {
tags.add(Tag.publicKey(pubkey))
}
}
for (receiver in listOf(currentUser) + to) {
for (receiver in setOf(currentUser) + to) {
// Construct the rumor event
// NEVER SIGN this event with the current user signer
val rumor = EventBuilder
.privateMsgRumor(receiver = receiver, message = content)
.tags(tags)
.allowSelfTagging()
.build(currentUser)
// Ensure the event ID is set
.ensureId()

View File

@@ -34,6 +34,7 @@ import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.blossom.BlossomClient
import su.reya.coop.storage.SecretStorage
import kotlin.time.Clock
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
class NostrViewModel(
@@ -177,7 +178,7 @@ class NostrViewModel(
val lastFlushTime = Clock.System.now().toEpochMilliseconds()
while (batch.isNotEmpty()) {
val nextKey = withTimeoutOrNull(timeout) {
val nextKey = withTimeoutOrNull(timeout.milliseconds) {
metadataRequestChannel.receive()
}
@@ -247,7 +248,7 @@ class NostrViewModel(
// Get chat rooms
val rooms = nostr.getChatRooms() ?: emptySet()
if (rooms.isNotEmpty()) {
_chatRooms.value = rooms
mergeChatRooms(rooms)
_isPartialProcessedGiftWrap.value = true
}
@@ -255,7 +256,7 @@ class NostrViewModel(
nostr.getUserMetadata()
// Small delay to ensure all relays are connected
delay(3000)
delay(3000.milliseconds)
// Check if the relay list is empty
val relays = nostr.getMsgRelays(pubkey)
@@ -266,7 +267,7 @@ class NostrViewModel(
break
}
delay(500)
delay(500.milliseconds)
}
}
}
@@ -475,7 +476,7 @@ class NostrViewModel(
// Update the chat rooms state
_chatRooms.update { currentRooms ->
currentRooms + room
(currentRooms + room).sortedDescending().toSet()
}
return room.id
@@ -489,21 +490,29 @@ class NostrViewModel(
?: throw IllegalArgumentException("Room not found")
}
private fun mergeChatRooms(rooms: Set<Room>) {
_chatRooms.update { currentRooms ->
val merged = currentRooms.associateBy { it.id }.toMutableMap()
// Add or update rooms from the database
rooms.forEach { room ->
merged[room.id] = room
}
// Return as a sorted set to maintain UI consistency
merged.values.sortedDescending().toSet()
}
}
fun getChatRooms() {
viewModelScope.launch {
val rooms = nostr.getChatRooms() ?: emptySet()
_chatRooms.update { currentRooms ->
val virtualRooms = currentRooms.filter { local ->
rooms.none { db -> db.id == local.id }
}
rooms + virtualRooms
}
mergeChatRooms(rooms)
}
}
suspend fun refreshChatRooms() {
try {
_chatRooms.value = nostr.getChatRooms() ?: emptySet()
val rooms = nostr.getChatRooms() ?: emptySet()
mergeChatRooms(rooms)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
@@ -539,7 +548,7 @@ class NostrViewModel(
try {
val room = getChatRoom(roomId)
nostr.sendMessage(
to = room.members.toList(),
to = room.members,
content = message,
subject = room.subject,
replies = replies,