From d56847f5d4119ea781dcd7a878531678daf7e3c7 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 15 May 2026 09:41:50 +0700 Subject: [PATCH] optimistic update message on send --- .../kotlin/su/reya/coop/screens/ChatScreen.kt | 43 +++++++++++++------ .../kotlin/su/reya/coop/screens/HomeScreen.kt | 1 + .../commonMain/kotlin/su/reya/coop/Nostr.kt | 37 ++++++++++------ .../kotlin/su/reya/coop/NostrViewModel.kt | 7 ++- .../commonMain/kotlin/su/reya/coop/Room.kt | 21 +++++++++ 5 files changed, 84 insertions(+), 25 deletions(-) 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 a907668..2db5b00 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt @@ -42,7 +42,6 @@ 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.style.TextAlign import androidx.compose.ui.unit.dp import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.ic_arrow_back @@ -51,7 +50,7 @@ import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.UnsignedEvent import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState -import su.reya.coop.humanReadable +import su.reya.coop.formatAsGroupHeader import su.reya.coop.roomId import su.reya.coop.shared.Avatar import su.reya.coop.shared.displayNameFlow @@ -73,7 +72,10 @@ fun ChatScreen( var loading by remember { mutableStateOf(true) } val messages = remember { mutableStateListOf() } - + val groupedMessages = remember(messages.toList()) { + messages.groupBy { it.createdAt().formatAsGroupHeader() } + } + fun setLoading(value: Boolean) { loading = value } @@ -96,7 +98,9 @@ fun ChatScreen( // Handle new messages viewModel.newEvents.collect { event -> if (event.roomId() == id) { - messages.add(0, event) + if (event.id() !in messages.map { it.id() }) { + messages.add(0, event) + } } } } @@ -163,8 +167,13 @@ fun ChatScreen( contentPadding = PaddingValues(16.dp), reverseLayout = true ) { - items(messages.toList(), key = { it.id()?.toBech32()!! }) { event -> - ChatMessage(event) + groupedMessages.forEach { (dateHeader, messagesInGroup) -> + items(messagesInGroup, key = { it.id()?.toBech32()!! }) { event -> + ChatMessage(event) + } + item { + DateSeparator(dateHeader) + } } } ChatInput( @@ -182,6 +191,22 @@ fun ChatScreen( ) } +@Composable +fun DateSeparator(date: String) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = date, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.outline + ) + } +} + @Composable fun ChatMessage( rumor: UnsignedEvent @@ -211,12 +236,6 @@ fun ChatMessage( Column( horizontalAlignment = if (isMine) Alignment.End else Alignment.Start ) { - Text( - text = rumor.createdAt().humanReadable(), - style = MaterialTheme.typography.labelSmall, - textAlign = if (isMine) TextAlign.End else TextAlign.Start, - ) - Spacer(modifier = Modifier.size(4.dp)) Surface( color = containerColor, contentColor = contentColor, diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt index da90657..4f0f03d 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -179,6 +179,7 @@ fun HomeScreen(onOpenChat: (Long) -> Unit) { picture = userProfile?.asRecord()?.picture, description = userProfile?.asRecord()?.displayName, shape = MaterialShapes.Cookie9Sided.toShape(), + modifier = Modifier.fillMaxSize() ) } Spacer(modifier = Modifier.size(8.dp)) diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index 107d4cb..9607d23 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -42,8 +42,8 @@ import rust.nostr.sdk.TagKind import rust.nostr.sdk.Timestamp import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.UnwrappedGift +import rust.nostr.sdk.giftWrapAsync import rust.nostr.sdk.initLogger -import rust.nostr.sdk.makePrivateMsgAsync import rust.nostr.sdk.nip17ExtractRelayList import kotlin.time.Duration @@ -551,7 +551,8 @@ class Nostr { to: List, content: String, subject: String? = null, - replies: List = emptyList() + replies: List = emptyList(), + onNewMessage: ((UnsignedEvent) -> Unit)? = null ) { try { val currentUser = @@ -578,20 +579,32 @@ class Nostr { } } - for (receiver in to.plus(currentUser)) { - // Construct the gift wrap event - val event = makePrivateMsgAsync( - signer = signer, - receiver = receiver, - message = content, - rumorExtraTags = tags - ) + for (receiver in listOf(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) + .build(currentUser) + // Ensure the event ID is set + .ensureId() - println("Sending message to: ${receiver.toBech32()}") + // Emit the rumor to the chat screen + if (receiver == currentUser) { + onNewMessage?.invoke(rumor) + } + + // Construct the gift wrap event + val gift = giftWrapAsync( + signer = signer, + receiverPubkey = receiver, + rumor = rumor, + extraTags = tags + ) // Send the event to receiver's NIP-17 relays client?.sendEvent( - event = event, + event = gift, target = SendEventTarget.toNip17(), ackPolicy = AckPolicy.none(), authenticationTimeout = Duration.parse("2s") diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index d55ad51..c5df147 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -333,7 +333,12 @@ class NostrViewModel( to = room.members.toList(), content = message, subject = room.subject, - replies = replies + replies = replies, + onNewMessage = { event -> + viewModelScope.launch { + _newEvents.emit(event) + } + } ) } catch (e: Exception) { showError("Error: ${e.message}") diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt index 22b65bc..c0260e1 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt @@ -125,6 +125,27 @@ fun Timestamp.ago(): String { } } +fun Timestamp.formatAsGroupHeader(): String { + val timeZone = TimeZone.currentSystemDefault() + val inputInstant = Instant.fromEpochSeconds(this.asSecs().toLong()) + val inputDate = inputInstant.toLocalDateTime(timeZone).date + + val now = Clock.System.now() + val today = now.toLocalDateTime(timeZone).date + val yesterday = today.minus(1, DateTimeUnit.DAY) + + return when (inputDate) { + today -> "Today" + yesterday -> "Yesterday" + else -> { + val day = inputDate.day.toString().padStart(2, '0') + val month = inputDate.month.number.toString().padStart(2, '0') + val year = inputDate.year.toString().takeLast(2) + "$day/$month/$year" + } + } +} + fun Timestamp.humanReadable(): String { val timeZone = TimeZone.currentSystemDefault() val inputInstant = Instant.fromEpochSeconds(this.asSecs().toLong())