Compare commits

..
3 Commits
6 changed files with 400 additions and 98 deletions
@@ -5,9 +5,13 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement 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.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@@ -34,6 +38,7 @@ import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import rust.nostr.sdk.EventId import rust.nostr.sdk.EventId
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
@@ -46,6 +51,13 @@ import su.reya.coop.formatAsTime
import su.reya.coop.isImageUrl import su.reya.coop.isImageUrl
import su.reya.coop.removeImageUrls import su.reya.coop.removeImageUrls
@Immutable
data class ReactionGroup(
val emoji: String,
val authors: List<PublicKey>,
val containsMe: Boolean
)
@Immutable @Immutable
data class MessageModel( data class MessageModel(
val id: EventId, val id: EventId,
@@ -54,15 +66,20 @@ data class MessageModel(
val images: List<String>, val images: List<String>,
val timestamp: String, val timestamp: String,
val isMine: Boolean, val isMine: Boolean,
val replyEventIds: List<EventId> val replyEventIds: List<EventId>,
val reactions: List<ReactionGroup> = emptyList()
) )
@Composable @Composable
fun rememberMessageModel(event: UnsignedEvent, currentUser: PublicKey? = null): MessageModel { fun rememberMessageModel(
event: UnsignedEvent,
reactions: List<UnsignedEvent> = emptyList(),
currentUser: PublicKey? = null
): MessageModel {
val settings = LocalSettings.current val settings = LocalSettings.current
val isMobileData = LocalConnectivity.current val isMobileData = LocalConnectivity.current
return remember(event, currentUser, settings, isMobileData) { return remember(event, reactions, currentUser, settings, isMobileData) {
val id = event.ensureId().id()!! val id = event.ensureId().id()!!
val isMine = currentUser == event.author() val isMine = currentUser == event.author()
val content = event.content() val content = event.content()
@@ -104,6 +121,16 @@ fun rememberMessageModel(event: UnsignedEvent, currentUser: PublicKey? = null):
append(cleanedContent.substring(lastIndex)) append(cleanedContent.substring(lastIndex))
} }
val groupedReactions = reactions.groupBy { it.content() }
.map { (emoji, events) ->
val authors = events.map { it.author() }
ReactionGroup(
emoji = emoji,
authors = authors,
containsMe = authors.any { it == currentUser }
)
}
MessageModel( MessageModel(
id = id, id = id,
author = event.author(), author = event.author(),
@@ -111,7 +138,8 @@ fun rememberMessageModel(event: UnsignedEvent, currentUser: PublicKey? = null):
images = images, images = images,
timestamp = event.createdAt().formatAsTime(), timestamp = event.createdAt().formatAsTime(),
isMine = isMine, isMine = isMine,
replyEventIds = replyEventIds replyEventIds = replyEventIds,
reactions = groupedReactions
) )
} }
} }
@@ -158,46 +186,108 @@ fun ChatMessage(
horizontalAlignment = if (model.isMine) Alignment.End else Alignment.Start, horizontalAlignment = if (model.isMine) Alignment.End else Alignment.Start,
verticalArrangement = Arrangement.spacedBy(4.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
if (model.annotatedContent.isNotBlank()) { Box(contentAlignment = Alignment.BottomEnd) {
Surface( Column(
modifier = Modifier.widthIn(max = 280.dp), modifier = Modifier.padding(
color = containerColor, bottom = if (model.reactions.isNotEmpty()) 4.dp else 0.dp,
contentColor = contentColor, end = if (model.reactions.isNotEmpty() && !model.isMine) 8.dp else 0.dp
shape = bubbleShape, ),
horizontalAlignment = if (model.isMine) Alignment.End else Alignment.Start,
verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
Text( if (model.annotatedContent.isNotBlank()) {
text = model.annotatedContent, Surface(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), modifier = Modifier.widthIn(max = 280.dp),
style = MaterialTheme.typography.bodyLarge color = containerColor,
) contentColor = contentColor,
shape = bubbleShape,
) {
Text(
text = model.annotatedContent,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
style = MaterialTheme.typography.bodyLarge
)
}
}
model.images.forEach { imageUrl ->
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.widthIn(max = 280.dp)
) {
AsyncImage(
model = imageUrl,
contentDescription = "Image from chat",
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp)),
contentScale = ContentScale.FillWidth
)
}
}
} }
}
model.images.forEach { imageUrl -> if (model.reactions.isNotEmpty()) {
Surface( MessageReactions(
shape = RoundedCornerShape(16.dp), reactions = model.reactions,
color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.offset(y = 12.dp)
modifier = Modifier.widthIn(max = 280.dp)
) {
AsyncImage(
model = imageUrl,
contentDescription = "Image from chat",
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp)),
contentScale = ContentScale.FillWidth
) )
} }
} }
if (isMessageClicked) { if (isMessageClicked) {
Text( Text(
text = model.timestamp, text = model.timestamp,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline, color = MaterialTheme.colorScheme.outline,
modifier = Modifier.align( modifier = Modifier.padding(top = if (model.reactions.isNotEmpty()) 8.dp else 0.dp)
if (model.isMine) Alignment.End else Alignment.Start
)
) )
} }
} }
} }
} }
@Composable
private fun MessageReactions(
reactions: List<ReactionGroup>,
modifier: Modifier = Modifier
) {
val totalCount = reactions.sumOf { it.authors.size }
val displayEmojis = reactions.take(3).map { it.emoji }
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
displayEmojis.forEach { emoji ->
Surface(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.surface,
shape = CircleShape,
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = emoji,
fontSize = 12.sp,
)
}
}
}
if (totalCount > 2) {
Surface(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.surface,
shape = CircleShape,
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = totalCount.toString(),
style = MaterialTheme.typography.labelSmall,
fontSize = 10.sp,
)
}
}
}
}
}
@@ -80,6 +80,7 @@ import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
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
@@ -90,6 +91,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.EventId
import rust.nostr.sdk.KindStandard
import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalProfileCache import su.reya.coop.LocalProfileCache
@@ -143,10 +146,21 @@ fun ChatScreen(
val loading = viewModel.loading val loading = viewModel.loading
val newOtherMessages = viewModel.newOtherMessages val newOtherMessages = viewModel.newOtherMessages
val requireScreening = viewModel.requireScreening val requireScreening = viewModel.requireScreening
val messages = viewModel.messages val allEvents = viewModel.messages
val displayMessages by remember {
derivedStateOf { allEvents.filter { it.kind().asStd() != KindStandard.REACTION } }
}
val reactionsByMessage by remember {
derivedStateOf {
allEvents.filter { it.kind().asStd() == KindStandard.REACTION }
.groupBy { it.tags().eventIds().firstOrNull() }
}
}
val groupedMessages = val groupedMessages =
remember { derivedStateOf { messages.groupBy { it.createdAt().formatAsGroup() } } } remember { derivedStateOf { displayMessages.groupBy { it.createdAt().formatAsGroup() } } }
val roomState by remember(id, currentUser?.publicKey) { val roomState by remember(id, currentUser?.publicKey) {
(room as Room).uiStateFlow(profileCache, currentUser?.publicKey) (room as Room).uiStateFlow(profileCache, currentUser?.publicKey)
@@ -161,6 +175,29 @@ fun ChatScreen(
label = "blurAnimation" label = "blurAnimation"
) )
val goToMessage = { eventId: EventId? ->
if (eventId != null) {
scope.launch {
var targetIndex = -1
var currentIndex = 0
for (group in groupedMessages.value) {
val msgInGroup = group.value
val idx = msgInGroup.indexOfFirst { it.ensureId().id() == eventId }
if (idx != -1) {
targetIndex = currentIndex + idx
break
}
currentIndex += msgInGroup.size + 1
}
if (targetIndex != -1) {
listState.animateScrollToItem(targetIndex)
}
}
}
}
val sendFile = { uri: Uri -> val sendFile = { uri: Uri ->
scope.launch { scope.launch {
// Read file on IO dispatcher // Read file on IO dispatcher
@@ -190,15 +227,13 @@ fun ChatScreen(
} }
} }
LaunchedEffect(messages.size) { LaunchedEffect(allEvents.size) {
if (messages.isNotEmpty()) { if (displayMessages.isNotEmpty()) {
listState.animateScrollToItem(0) listState.animateScrollToItem(0)
} }
} }
Box( Box(modifier = Modifier.fillMaxSize()) {
modifier = Modifier.fillMaxSize()
) {
Scaffold( Scaffold(
modifier = Modifier.blur(blurAmount), modifier = Modifier.blur(blurAmount),
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime), contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime),
@@ -271,7 +306,7 @@ fun ChatScreen(
room?.let { ScreenerCard(accountViewModel, it) } room?.let { ScreenerCard(accountViewModel, it) }
} }
when (messages.isNotEmpty()) { when (displayMessages.isNotEmpty()) {
true -> { true -> {
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
@@ -286,14 +321,15 @@ fun ChatScreen(
items = messagesInGroup, items = messagesInGroup,
key = { it.ensureId().id()?.toHex()!! } key = { it.ensureId().id()?.toHex()!! }
) { event -> ) { event ->
val msgReactions = reactionsByMessage[event.id()] ?: emptyList()
val model = val model =
rememberMessageModel(event, currentUser?.publicKey) rememberMessageModel(event, msgReactions, currentUser?.publicKey)
val replyPreview = val replyPreview =
remember(model.replyEventIds, messages.size) { remember(model.replyEventIds, displayMessages.size) {
model.replyEventIds.firstOrNull() model.replyEventIds.firstOrNull()
?.let { replyId -> ?.let { replyId ->
messages.find { it.id() == replyId } displayMessages.find { it.ensureId().id() == replyId }
} }
} }
@@ -303,7 +339,13 @@ fun ChatScreen(
.animateItem(), .animateItem(),
verticalArrangement = Arrangement.spacedBy(2.dp) verticalArrangement = Arrangement.spacedBy(2.dp)
) { ) {
replyPreview?.let { ReplyPreview(it, model.isMine) } replyPreview?.let { previewEvent ->
ReplyPreview(
event = previewEvent,
isMine = model.isMine,
onClick = { goToMessage(previewEvent.id()) }
)
}
ChatMessage( ChatMessage(
model = model, model = model,
modifier = Modifier.graphicsLayer { modifier = Modifier.graphicsLayer {
@@ -394,8 +436,9 @@ fun ChatScreen(
value = text, value = text,
onValueChange = { text = it }, onValueChange = { text = it },
onSend = { onSend = {
viewModel.sendMessage(text) viewModel.sendMessage(text, replyingTo?.id)
text = "" text = ""
replyingTo = null
}, },
onUpload = { onUpload = {
fileLauncher.launch("image/*") fileLauncher.launch("image/*")
@@ -435,14 +478,11 @@ fun ChatScreen(
val (model, bounds) = selectedMessage ?: return@AnimatedVisibility val (model, bounds) = selectedMessage ?: return@AnimatedVisibility
val density = LocalDensity.current val density = LocalDensity.current
val windowInfo = LocalWindowInfo.current
val windowHeight = windowInfo.containerSize.height
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
var menuHeight by remember { mutableFloatStateOf(0f) } var menuHeight by remember { mutableFloatStateOf(0f) }
val spacing = with(density) { 12.dp.toPx() } var toolbarHeight by remember { mutableFloatStateOf(0f) }
val showAbove = val spacing = with(density) { 6.dp.toPx() }
(windowHeight - bounds.bottom) < (menuHeight + spacing) && bounds.top > (menuHeight + spacing)
Box( Box(
modifier = Modifier modifier = Modifier
@@ -451,11 +491,30 @@ fun ChatScreen(
.clickable { selectedMessage = null } .clickable { selectedMessage = null }
.verticalScroll(scrollState), .verticalScroll(scrollState),
) { ) {
val totalExtraHeight = if (menuHeight > 0) menuHeight + spacing else 300f val totalExtraHeight = (if (menuHeight > 0) menuHeight + spacing else 300f) +
(if (toolbarHeight > 0) toolbarHeight + spacing else 100f)
val contentBottom = with(density) { (bounds.bottom + totalExtraHeight).toDp() } val contentBottom = with(density) { (bounds.bottom + totalExtraHeight).toDp() }
Spacer(modifier = Modifier.height(contentBottom + 200.dp)) Spacer(modifier = Modifier.height(contentBottom + 200.dp))
// Reaction Toolbar (Above)
Box(
modifier = Modifier
.offset { IntOffset(0, (bounds.top - toolbarHeight - spacing).toInt().coerceAtLeast(0)) }
.onGloballyPositioned { toolbarHeight = it.size.height.toFloat() }
.fillMaxWidth()
.padding(horizontal = 16.dp),
contentAlignment = if (model.isMine) Alignment.CenterEnd else Alignment.CenterStart
) {
ReactionToolbar(
onReaction = { reaction ->
viewModel.sendReaction(model.id, reaction)
selectedMessage = null
}
)
}
// Message Preview
ChatMessage( ChatMessage(
model = model, model = model,
modifier = Modifier modifier = Modifier
@@ -463,38 +522,35 @@ fun ChatScreen(
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
) )
val menuOffset = if (showAbove) { // Action Menu (Below)
bounds.top - menuHeight - spacing
} else {
bounds.bottom + spacing
}
Box( Box(
modifier = Modifier modifier = Modifier
.offset { IntOffset(0, menuOffset.toInt().coerceAtLeast(0)) } .offset { IntOffset(0, (bounds.bottom + spacing).toInt()) }
.onGloballyPositioned { menuHeight = it.size.height.toFloat() } .onGloballyPositioned { menuHeight = it.size.height.toFloat() }
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
contentAlignment = if (model.isMine) Alignment.CenterEnd else Alignment.CenterStart contentAlignment = if (model.isMine) Alignment.CenterEnd else Alignment.CenterStart
) { ) {
ContextMenu { action -> ContextMenu(
when (action) { onAction = { action ->
"Copy" -> { when (action) {
scope.launch { "Copy" -> {
val content = model.annotatedContent scope.launch {
val data = ClipData.newPlainText(content, content) val content = model.annotatedContent
clipboardManager.setClipEntry(ClipEntry(data)) val data = ClipData.newPlainText(content, content)
clipboardManager.setClipEntry(ClipEntry(data))
}
} }
}
"Reply" -> { "Reply" -> {
replyingTo = model replyingTo = model
} }
else -> {} else -> {}
}
selectedMessage = null
} }
selectedMessage = null )
}
} }
} }
} }
@@ -544,7 +600,11 @@ private fun ReplyBox(model: MessageModel, onDismiss: () -> Unit) {
} }
@Composable @Composable
private fun ReplyPreview(event: UnsignedEvent, isMine: Boolean = false) { private fun ReplyPreview(
event: UnsignedEvent,
isMine: Boolean = false,
onClick: () -> Unit
) {
val profileCache = LocalProfileCache.current val profileCache = LocalProfileCache.current
val profileFlow = remember(event) { profileCache.getMetadata(event.author()) } val profileFlow = remember(event) { profileCache.getMetadata(event.author()) }
val profile by profileFlow.collectAsStateWithLifecycle() val profile by profileFlow.collectAsStateWithLifecycle()
@@ -560,7 +620,9 @@ private fun ReplyPreview(event: UnsignedEvent, isMine: Boolean = false) {
contentAlignment = if (isMine) Alignment.CenterEnd else Alignment.CenterStart contentAlignment = if (isMine) Alignment.CenterEnd else Alignment.CenterStart
) { ) {
Surface( Surface(
modifier = Modifier.widthIn(max = 280.dp), modifier = Modifier
.widthIn(max = 280.dp)
.clickable(onClick = onClick),
color = MaterialTheme.colorScheme.tertiaryContainer, color = MaterialTheme.colorScheme.tertiaryContainer,
shape = bubbleShape, shape = bubbleShape,
) { ) {
@@ -568,13 +630,13 @@ private fun ReplyPreview(event: UnsignedEvent, isMine: Boolean = false) {
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp), .padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) { ) {
Text( Text(
text = profile?.name ?: "Unknown", text = profile?.name ?: "Unknown",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onTertiaryContainer.copy( fontWeight = FontWeight.SemiBold,
alpha = 0.6f color = MaterialTheme.colorScheme.onTertiaryContainer,
),
) )
Text( Text(
text = event.content(), text = event.content(),
@@ -587,9 +649,41 @@ private fun ReplyPreview(event: UnsignedEvent, isMine: Boolean = false) {
} }
} }
@Composable
private fun ReactionToolbar(
onReaction: (String) -> Unit
) {
val reactionEmojis = listOf("👍", "❤️", "😂", "😮", "😢", "😡", "🎉")
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(24.dp),
shadowElevation = 1.dp
) {
Row(
modifier = Modifier
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
reactionEmojis.forEach { emoji ->
Text(
text = emoji,
modifier = Modifier
.clickable { onReaction(emoji) }
.padding(4.dp),
fontSize = 24.sp
)
}
}
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
private fun ContextMenu(onAction: (String) -> Unit) { private fun ContextMenu(
onAction: (String) -> Unit
) {
val menuItems = listOf( val menuItems = listOf(
"Copy" to Res.drawable.ic_copy, "Copy" to Res.drawable.ic_copy,
"Reply" to Res.drawable.ic_reply "Reply" to Res.drawable.ic_reply
@@ -598,6 +692,8 @@ private fun ContextMenu(onAction: (String) -> Unit) {
DropdownMenuGroup( DropdownMenuGroup(
shapes = MenuDefaults.groupShape(1, 1), shapes = MenuDefaults.groupShape(1, 1),
containerColor = MenuDefaults.groupVibrantContainerColor, containerColor = MenuDefaults.groupVibrantContainerColor,
tonalElevation = 1.dp,
shadowElevation = 1.dp,
modifier = Modifier.width(220.dp) modifier = Modifier.width(220.dp)
) { ) {
val itemCount = menuItems.size val itemCount = menuItems.size
@@ -144,12 +144,15 @@ class MessageManager(private val nostr: Nostr) {
private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) { private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) {
try { try {
val isReaction = rumor.kind().asStd() == KindStandard.REACTION
val kValue = if (isReaction) "reaction" else "dm"
// Construct reference tags // Construct reference tags
val tags = listOf( val tags = listOf(
Tag.identifier(giftId.toHex()), Tag.identifier(giftId.toHex()),
Tag.publicKey(rumor.author()), Tag.publicKey(rumor.author()),
Tag.custom("r", listOf(rumor.roomId().toString())), Tag.custom("r", listOf(rumor.roomId().toString())),
Tag.custom("k", listOf("14")) Tag.custom("k", listOf("14", kValue))
) )
// Set event kind // Set event kind
@@ -176,12 +179,13 @@ class MessageManager(private val nostr: Nostr) {
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA) val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
val kTag = SingleLetterTag.lowercase(Alphabet.K) val kTag = SingleLetterTag.lowercase(Alphabet.K)
// Get all DM events // Get all rumors (DMs and Reactions)
val filter = Filter().kind(kind).customTags(kTag, listOf("14", "dm")) val filter = Filter().kind(kind).customTags(kTag, listOf("14", "dm", "reaction"))
val events = client?.database()?.query(filter)?.toVec() ?: return null val events = client?.database()?.query(filter)?.toVec() ?: return null
// Collect rooms // Collect rooms
val roomsMap: MutableMap<Long, Room> = mutableMapOf() val roomsMap: MutableMap<Long, Room> = mutableMapOf()
val lastDmTimestampMap: MutableMap<Long, ULong> = mutableMapOf()
events events
.map { UnsignedEvent.fromJson(it.content()) } .map { UnsignedEvent.fromJson(it.content()) }
@@ -189,18 +193,41 @@ class MessageManager(private val nostr: Nostr) {
.forEach { rumor -> .forEach { rumor ->
val id = rumor.roomId() val id = rumor.roomId()
val isFromMe = rumor.author() == userPubkey val isFromMe = rumor.author() == userPubkey
val isReaction = rumor.kind().asStd() == KindStandard.REACTION
val existing = roomsMap[id] val existing = roomsMap[id]
val createdAt = rumor.createdAt() val createdAt = rumor.createdAt()
// If the room is new or the current rumor is newer than the existing one if (existing == null) {
if (existing == null || createdAt.asSecs() > existing.createdAt.asSecs()) {
// A room is "Ongoing" if it was already marked as such or if the current rumor is from the user
val isOngoing = (existing?.kind == RoomKind.Ongoing) || isFromMe
val room = Room.new(rumor = rumor, userPubkey = userPubkey, id = id) val room = Room.new(rumor = rumor, userPubkey = userPubkey, id = id)
roomsMap[id] = if (isOngoing) room.copy(kind = RoomKind.Ongoing) else room // If the first event we see is a reaction, don't use it as lastMessage
} else if (isFromMe && existing.kind != RoomKind.Ongoing) { roomsMap[id] = if (isReaction) {
// If it's an older rumor but sent by the user, mark the room as Ongoing room.copy(lastMessage = null)
roomsMap[id] = existing.copy(kind = RoomKind.Ongoing) } else {
lastDmTimestampMap[id] = createdAt.asSecs()
room
}
if (isFromMe) {
roomsMap[id] = roomsMap[id]!!.copy(kind = RoomKind.Ongoing)
}
} else {
// Update the overall room timestamp (for sorting) if this event is newer
if (createdAt.asSecs() > existing.createdAt.asSecs()) {
roomsMap[id] = roomsMap[id]!!.copy(createdAt = createdAt)
}
// Update the last message content if this is a DM and it's newer than the last DM we've seen
if (!isReaction) {
val lastDmTs = lastDmTimestampMap[id] ?: 0uL
if (createdAt.asSecs() >= lastDmTs) {
lastDmTimestampMap[id] = createdAt.asSecs()
roomsMap[id] = roomsMap[id]!!.copy(lastMessage = rumor.content())
}
}
// If any event is from the user, mark the room as Ongoing
if (isFromMe && roomsMap[id]?.kind != RoomKind.Ongoing) {
roomsMap[id] = roomsMap[id]!!.copy(kind = RoomKind.Ongoing)
}
} }
} }
@@ -348,4 +375,64 @@ class MessageManager(private val nostr: Nostr) {
throw IllegalStateException("Failed to send message: ${e.message}", e) throw IllegalStateException("Failed to send message: ${e.message}", e)
} }
} }
suspend fun sendReaction(
to: Set<PublicKey>,
targetEventId: EventId,
reaction: String,
onRumorCreated: ((UnsignedEvent) -> Unit)? = null,
) {
try {
val currentUser =
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
val tags = mutableListOf<Tag>()
tags.add(Tag.event(targetEventId))
// Add public key tags for each recipient (including me) to ensure roomId consistency
to.forEach { pubkey ->
tags.add(Tag.publicKey(pubkey))
}
for (receiver in setOf(currentUser) + to) {
// Construct the rumor event
val rumor = EventBuilder(Kind.fromStd(KindStandard.REACTION), reaction)
.tags(tags)
.finalizeUnsigned(currentUser)
.ensureId()
// Emit the rumor to the chat screen
if (receiver == currentUser) {
onRumorCreated?.invoke(rumor)
}
// Construct the gift wrap event
val gift = nip59MakeGiftWrapAsync(
signer = signer,
receiverPubkey = receiver,
rumor = rumor,
extraTags = listOf(
Tag.custom("k", listOf("14"))
)
)
// Send the event to receiver's NIP-17 relays
val output = client?.sendEvent(
event = gift,
target = SendEventTarget.toNip17(),
ackPolicy = AckPolicy.none(),
authenticationTimeout = Duration.parse("2s")
)
if (output != null) {
// Keep track of rumor IDs
val id = rumor.id() ?: throw IllegalStateException("Rumor ID is null")
rumorMap[id] = output.id
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
throw IllegalStateException("Failed to send reaction: ${e.message}", e)
}
}
} }
@@ -22,13 +22,15 @@ import kotlin.time.Duration
class RelayManager(private val nostr: Nostr) { class RelayManager(private val nostr: Nostr) {
companion object { companion object {
val BOOTSTRAP_RELAYS = listOf( val BOOTSTRAP_RELAYS = listOf(
"wss://relay.primal.net",
"wss://relay.ditto.pub", "wss://relay.ditto.pub",
"wss://user.kindpag.es", "wss://relay.primal.net",
"wss://relay.nostr.net",
"wss://profiles.nostr1.com",
) )
val INDEXER_RELAY = listOf( val INDEXER_RELAY = listOf(
"wss://indexer.coracle.social", "wss://indexer.coracle.social",
"wss://user.kindpag.es",
) )
val ALL_RELAYS = BOOTSTRAP_RELAYS + INDEXER_RELAY val ALL_RELAYS = BOOTSTRAP_RELAYS + INDEXER_RELAY
@@ -241,8 +241,29 @@ class ChatRepository(
} }
} }
fun sendReaction(roomId: Long, targetEventId: EventId, reaction: String) {
scope.launch(defaultDispatcher) {
try {
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
nostr.messages.sendReaction(
to = room.members,
targetEventId = targetEventId,
reaction = reaction,
onRumorCreated = {
scope.launch(defaultDispatcher) {
updateRoomState(it, roomId)
}
},
)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
private suspend fun updateRoomState(event: UnsignedEvent, roomId: Long = event.roomId()) { private suspend fun updateRoomState(event: UnsignedEvent, roomId: Long = event.roomId()) {
val currentUser = nostr.signer.getPublicKeyAsync() ?: return val currentUser = nostr.signer.getPublicKeyAsync() ?: return
val isReaction = event.kind().asStd() == KindStandard.REACTION
_state.update { currentState -> _state.update { currentState ->
val rooms = currentState.rooms.toMutableMap() val rooms = currentState.rooms.toMutableMap()
@@ -256,22 +277,24 @@ class ChatRepository(
// New room discovery // New room discovery
val newRoom = Room.new(event, currentUser, roomId).copy( val newRoom = Room.new(event, currentUser, roomId).copy(
kind = newKind, kind = newKind,
unreadCount = if (isFromMe) 0 else 1 unreadCount = if (isFromMe || isReaction) 0 else 1,
lastMessage = if (isReaction) null else event.content()
) )
rooms[newRoom.id] = newRoom rooms[newRoom.id] = newRoom
} else if (event.createdAt().asSecs() >= existingRoom.createdAt.asSecs()) { } else if (event.createdAt().asSecs() >= existingRoom.createdAt.asSecs()) {
// Only update preview if message is newer (handles sync/late arrivals) // Update timestamp for any newer event (DM or Reaction)
// But only update preview if it's a DM
rooms[roomId] = existingRoom.copy( rooms[roomId] = existingRoom.copy(
lastMessage = event.content(), lastMessage = if (isReaction) existingRoom.lastMessage else event.content(),
createdAt = event.createdAt(), createdAt = event.createdAt(),
kind = newKind, kind = newKind,
unreadCount = if (isFromMe) existingRoom.unreadCount else existingRoom.unreadCount + 1 unreadCount = if (isFromMe || isReaction) existingRoom.unreadCount else existingRoom.unreadCount + 1
) )
} else if (isFromMe && existingRoom.kind != RoomKind.Ongoing) { } else if (isFromMe && existingRoom.kind != RoomKind.Ongoing) {
// Even if it's an older message, if it's from me, the room is ongoing // Even if it's an older message or reaction, if it's from me, the room is ongoing
rooms[roomId] = existingRoom.copy(kind = RoomKind.Ongoing) rooms[roomId] = existingRoom.copy(kind = RoomKind.Ongoing)
} else { } else {
// Don't update the room list state for older messages // Don't update the room list state for older messages or reactions that don't change preview
return@update currentState return@update currentState
} }
currentState.copy(rooms = rooms) currentState.copy(rooms = rooms)
@@ -74,4 +74,8 @@ class ChatScreenViewModel(
fun sendFileMessage(file: ByteArray?, type: String?) { fun sendFileMessage(file: ByteArray?, type: String?) {
chatRepository.sendFileMessage(id, file, type) chatRepository.sendFileMessage(id, file, type)
} }
fun sendReaction(targetEventId: EventId, reaction: String) {
chatRepository.sendReaction(id, targetEventId, reaction)
}
} }