feat: add self-chat (#13)

Reviewed-on: #13
This commit was merged in pull request #13.
This commit is contained in:
2026-06-04 01:59:38 +00:00
parent fcae7d5825
commit ec337b8756
5 changed files with 58 additions and 37 deletions

View File

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

View File

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

View File

@@ -9,25 +9,32 @@ import su.reya.coop.Room
import su.reya.coop.short import su.reya.coop.short
fun Room.displayNameFlow(viewModel: NostrViewModel): Flow<String> { 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()) { if (isGroup()) {
val profiles = metadataArray.map { it?.asRecord() } val combined = names.joinToString(", ")
val names = profiles.take(2).mapNotNull { it?.name ?: it?.displayName } val extraCount = members.size - names.size
var combined = names.joinToString(", ") if (extraCount > 0) "$combined, +$extraCount" else combined
if (profiles.size > 2) combined += ", +${profiles.size - 2}"
combined.ifBlank { "Unknown group" }
} else { } else {
val profile = metadataArray.firstOrNull()?.asRecord() val name = names.first()
profile?.name ?: profile?.displayName ?: members.firstOrNull()?.short() ?: "Unknown" if (displayMembers.first() == viewModel.currentUser()) "$name (you)" else name
} }
} }
} }
fun Room.pictureFlow(viewModel: NostrViewModel): Flow<String?> { 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 } 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.initLogger
import rust.nostr.sdk.nip17ExtractRelayList import rust.nostr.sdk.nip17ExtractRelayList
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
object NostrManager { object NostrManager {
val instance = Nostr() val instance = Nostr()
@@ -228,7 +229,7 @@ class Nostr {
client?.subscribe( client?.subscribe(
target = ReqTarget.manual(target), target = ReqTarget.manual(target),
id = "all-gift-wraps" id = "gift-wraps"
) )
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to fetch user messages: ${e.message}", e) throw IllegalStateException("Failed to fetch user messages: ${e.message}", e)
@@ -293,7 +294,7 @@ class Nostr {
eoseTrackerJob?.cancel() eoseTrackerJob?.cancel()
// Start a new tracker // Start a new tracker
eoseTrackerJob = launch { eoseTrackerJob = launch {
delay(10000) // Wait for 10 seconds delay(10000.milliseconds) // Wait for 10 seconds
onSubscriptionClose() onSubscriptionClose()
} }
@@ -312,7 +313,7 @@ class Nostr {
is RelayMessageEnum.EndOfStoredEvents -> { is RelayMessageEnum.EndOfStoredEvents -> {
val subscriptionId = message.subscriptionId val subscriptionId = message.subscriptionId
if (subscriptionId == "all-gift-wraps" || subscriptionId == "newest-gift-wraps") { if (subscriptionId == "gift-wraps") {
onSubscriptionClose() onSubscriptionClose()
} }
} }
@@ -366,7 +367,7 @@ class Nostr {
Tag.identifier(giftId.toHex()), Tag.identifier(giftId.toHex()),
Tag.event(rumor.id()!!), Tag.event(rumor.id()!!),
Tag.reference(roomId.toString()), Tag.reference(roomId.toString()),
Tag.custom(TagKind.Unknown("k"), listOf("dm")) Tag.custom(TagKind.Unknown("k"), listOf("14"))
) )
// Set event kind // Set event kind
@@ -395,7 +396,6 @@ class Nostr {
// Try to unwrap the gift with each signer // Try to unwrap the gift with each signer
for (signer in signers) { for (signer in signers) {
try { try {
// TODO: custom unwrapping logic
val gift = UnwrappedGift.fromGiftWrapAsync(signer = signer, giftWrap = event) val gift = UnwrappedGift.fromGiftWrapAsync(signer = signer, giftWrap = event)
val rumor = gift.rumor() val rumor = gift.rumor()
// Save the rumor to the database // Save the rumor to the database
@@ -644,6 +644,8 @@ class Nostr {
return events return events
?.toVec() ?.toVec()
?.map { UnsignedEvent.fromJson(it.content()) } ?.map { UnsignedEvent.fromJson(it.content()) }
// Filter out events without public keys (receivers)
?.filter { it.tags().publicKeys().isNotEmpty() }
?.sortedByDescending { it.createdAt().asSecs() } ?: emptyList() ?.sortedByDescending { it.createdAt().asSecs() } ?: emptyList()
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to get chat room messages: ${e.message}", e) throw IllegalStateException("Failed to get chat room messages: ${e.message}", e)
@@ -697,7 +699,7 @@ class Nostr {
} }
suspend fun sendMessage( suspend fun sendMessage(
to: List<PublicKey>, to: Set<PublicKey>,
content: String, content: String,
subject: String? = null, subject: String? = null,
replies: List<EventId> = emptyList(), replies: List<EventId> = emptyList(),
@@ -723,17 +725,16 @@ class Nostr {
// Add public key tags for each recipient // Add public key tags for each recipient
to.forEach { pubkey -> 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 // Construct the rumor event
// NEVER SIGN this event with the current user signer // NEVER SIGN this event with the current user signer
val rumor = EventBuilder val rumor = EventBuilder
.privateMsgRumor(receiver = receiver, message = content) .privateMsgRumor(receiver = receiver, message = content)
.tags(tags) .tags(tags)
.allowSelfTagging()
.build(currentUser) .build(currentUser)
// Ensure the event ID is set // Ensure the event ID is set
.ensureId() .ensureId()

View File

@@ -248,7 +248,7 @@ class NostrViewModel(
// Get chat rooms // Get chat rooms
val rooms = nostr.getChatRooms() ?: emptySet() val rooms = nostr.getChatRooms() ?: emptySet()
if (rooms.isNotEmpty()) { if (rooms.isNotEmpty()) {
_chatRooms.value = rooms mergeChatRooms(rooms)
_isPartialProcessedGiftWrap.value = true _isPartialProcessedGiftWrap.value = true
} }
@@ -476,7 +476,7 @@ class NostrViewModel(
// Update the chat rooms state // Update the chat rooms state
_chatRooms.update { currentRooms -> _chatRooms.update { currentRooms ->
currentRooms + room (currentRooms + room).sortedDescending().toSet()
} }
return room.id return room.id
@@ -490,21 +490,29 @@ class NostrViewModel(
?: throw IllegalArgumentException("Room not found") ?: 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() { fun getChatRooms() {
viewModelScope.launch { viewModelScope.launch {
val rooms = nostr.getChatRooms() ?: emptySet() val rooms = nostr.getChatRooms() ?: emptySet()
_chatRooms.update { currentRooms -> mergeChatRooms(rooms)
val virtualRooms = currentRooms.filter { local ->
rooms.none { db -> db.id == local.id }
}
rooms + virtualRooms
}
} }
} }
suspend fun refreshChatRooms() { suspend fun refreshChatRooms() {
try { try {
_chatRooms.value = nostr.getChatRooms() ?: emptySet() val rooms = nostr.getChatRooms() ?: emptySet()
mergeChatRooms(rooms)
} catch (e: Exception) { } catch (e: Exception) {
showError("Error: ${e.message}") showError("Error: ${e.message}")
} }
@@ -540,7 +548,7 @@ class NostrViewModel(
try { try {
val room = getChatRoom(roomId) val room = getChatRoom(roomId)
nostr.sendMessage( nostr.sendMessage(
to = room.members.toList(), to = room.members,
content = message, content = message,
subject = room.subject, subject = room.subject,
replies = replies, replies = replies,