feat: add self-chat (#13)
Reviewed-on: #13
This commit was merged in pull request #13.
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user