diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt index 03a45ee..a627578 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt @@ -35,9 +35,7 @@ class NostrForegroundService : Service() { override fun onCreate() { super.onCreate() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - createNotificationChannel() - } + createNotificationChannel() val notification = createNotification() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt index 8300e02..1da9ef6 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt @@ -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( diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/shared/RoomHelper.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/shared/RoomHelper.kt index 5efe323..b87f789 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/shared/RoomHelper.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/shared/RoomHelper.kt @@ -9,25 +9,32 @@ import su.reya.coop.Room import su.reya.coop.short fun Room.displayNameFlow(viewModel: NostrViewModel): Flow { - if (!subject.isNullOrBlank()) return flowOf(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 { - 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 } -} +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index bbf83f7..1d654f0 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -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 @@ -644,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) @@ -697,7 +699,7 @@ class Nostr { } suspend fun sendMessage( - to: List, + to: Set, content: String, subject: String? = null, replies: List = emptyList(), @@ -723,17 +725,16 @@ class Nostr { // Add public key tags for each recipient to.forEach { pubkey -> - if (pubkey != currentUser) { - tags.add(Tag.publicKey(pubkey)) - } + 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() diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index 62007e2..a5fbb77 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -248,7 +248,7 @@ class NostrViewModel( // Get chat rooms val rooms = nostr.getChatRooms() ?: emptySet() if (rooms.isNotEmpty()) { - _chatRooms.value = rooms + mergeChatRooms(rooms) _isPartialProcessedGiftWrap.value = true } @@ -476,7 +476,7 @@ class NostrViewModel( // Update the chat rooms state _chatRooms.update { currentRooms -> - currentRooms + room + (currentRooms + room).sortedDescending().toSet() } return room.id @@ -490,21 +490,29 @@ class NostrViewModel( ?: throw IllegalArgumentException("Room not found") } + private fun mergeChatRooms(rooms: Set) { + _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}") } @@ -540,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,