feat: add support for reaction #52

Merged
reya merged 4 commits from feat/reaction into master 2026-09-07 13:30:43 +00:00
5 changed files with 257 additions and 34 deletions
Showing only changes of commit a8c73f804e - Show all commits
@@ -5,9 +5,14 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@@ -22,6 +27,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInWindow
@@ -34,6 +40,7 @@ import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import rust.nostr.sdk.EventId
import rust.nostr.sdk.PublicKey
@@ -46,6 +53,13 @@ import su.reya.coop.formatAsTime
import su.reya.coop.isImageUrl
import su.reya.coop.removeImageUrls
@Immutable
data class ReactionGroup(
val emoji: String,
val authors: List<PublicKey>,
val containsMe: Boolean
)
@Immutable
data class MessageModel(
val id: EventId,
@@ -54,15 +68,20 @@ data class MessageModel(
val images: List<String>,
val timestamp: String,
val isMine: Boolean,
val replyEventIds: List<EventId>
val replyEventIds: List<EventId>,
val reactions: List<ReactionGroup> = emptyList()
)
@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 isMobileData = LocalConnectivity.current
return remember(event, currentUser, settings, isMobileData) {
return remember(event, reactions, currentUser, settings, isMobileData) {
val id = event.ensureId().id()!!
val isMine = currentUser == event.author()
val content = event.content()
@@ -104,6 +123,16 @@ fun rememberMessageModel(event: UnsignedEvent, currentUser: PublicKey? = null):
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(
id = id,
author = event.author(),
@@ -111,7 +140,8 @@ fun rememberMessageModel(event: UnsignedEvent, currentUser: PublicKey? = null):
images = images,
timestamp = event.createdAt().formatAsTime(),
isMine = isMine,
replyEventIds = replyEventIds
replyEventIds = replyEventIds,
reactions = groupedReactions
)
}
}
@@ -188,6 +218,14 @@ fun ChatMessage(
)
}
}
if (model.reactions.isNotEmpty()) {
ReactionsRow(
reactions = model.reactions,
modifier = Modifier.padding(top = 2.dp)
)
}
if (isMessageClicked) {
Text(
text = model.timestamp,
@@ -201,3 +239,57 @@ fun ChatMessage(
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun ReactionsRow(
reactions: List<ReactionGroup>,
modifier: Modifier = Modifier
) {
FlowRow(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
reactions.forEach { group ->
ReactionChip(group)
}
}
}
@Composable
private fun ReactionChip(group: ReactionGroup) {
val backgroundColor = if (group.containsMe) {
MaterialTheme.colorScheme.secondaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
}
val contentColor = if (group.containsMe) {
MaterialTheme.colorScheme.onSecondaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
Surface(
color = backgroundColor,
contentColor = contentColor,
shape = CircleShape,
modifier = Modifier.padding(vertical = 2.dp)
) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(text = group.emoji, fontSize = 14.sp)
if (group.authors.size > 1) {
Text(
text = group.authors.size.toString(),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold
)
}
}
}
}
@@ -80,6 +80,7 @@ import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back
@@ -91,6 +92,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.EventId
import rust.nostr.sdk.KindStandard
import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalProfileCache
@@ -144,10 +146,21 @@ fun ChatScreen(
val loading = viewModel.loading
val newOtherMessages = viewModel.newOtherMessages
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 =
remember { derivedStateOf { messages.groupBy { it.createdAt().formatAsGroup() } } }
remember { derivedStateOf { displayMessages.groupBy { it.createdAt().formatAsGroup() } } }
val roomState by remember(id, currentUser?.publicKey) {
(room as Room).uiStateFlow(profileCache, currentUser?.publicKey)
@@ -170,7 +183,7 @@ fun ChatScreen(
for (group in groupedMessages.value) {
val msgInGroup = group.value
val idx = msgInGroup.indexOfFirst { it.id() == eventId }
val idx = msgInGroup.indexOfFirst { it.ensureId().id() == eventId }
if (idx != -1) {
targetIndex = currentIndex + idx
break
@@ -214,8 +227,8 @@ fun ChatScreen(
}
}
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) {
LaunchedEffect(allEvents.size) {
if (displayMessages.isNotEmpty()) {
listState.animateScrollToItem(0)
}
}
@@ -293,7 +306,7 @@ fun ChatScreen(
room?.let { ScreenerCard(accountViewModel, it) }
}
when (messages.isNotEmpty()) {
when (displayMessages.isNotEmpty()) {
true -> {
LazyColumn(
modifier = Modifier
@@ -308,14 +321,15 @@ fun ChatScreen(
items = messagesInGroup,
key = { it.ensureId().id()?.toHex()!! }
) { event ->
val msgReactions = reactionsByMessage[event.id()] ?: emptyList()
val model =
rememberMessageModel(event, currentUser?.publicKey)
rememberMessageModel(event, msgReactions, currentUser?.publicKey)
val replyPreview =
remember(model.replyEventIds, messages.size) {
remember(model.replyEventIds, displayMessages.size) {
model.replyEventIds.firstOrNull()
?.let { replyId ->
messages.find { it.id() == replyId }
displayMessages.find { it.ensureId().id() == replyId }
}
}
@@ -506,7 +520,8 @@ fun ChatScreen(
.padding(horizontal = 16.dp),
contentAlignment = if (model.isMine) Alignment.CenterEnd else Alignment.CenterStart
) {
ContextMenu { action ->
ContextMenu(
onAction = { action ->
when (action) {
"Copy" -> {
scope.launch {
@@ -523,7 +538,12 @@ fun ChatScreen(
else -> {}
}
selectedMessage = null
},
onReaction = { reaction ->
viewModel.sendReaction(model.id, reaction)
selectedMessage = null
}
)
}
}
}
@@ -624,17 +644,39 @@ private fun ReplyPreview(
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun ContextMenu(onAction: (String) -> Unit) {
private fun ContextMenu(
onAction: (String) -> Unit,
onReaction: (String) -> Unit
) {
val menuItems = listOf(
"Copy" to Res.drawable.ic_copy,
"Reply" to Res.drawable.ic_reply
)
val reactionEmojis = listOf("👍", "❤️", "👀", "🔥", "🚀", "🎉")
DropdownMenuGroup(
shapes = MenuDefaults.groupShape(1, 1),
containerColor = MenuDefaults.groupVibrantContainerColor,
modifier = Modifier.width(220.dp)
modifier = Modifier.width(240.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
reactionEmojis.forEach { emoji ->
Text(
text = emoji,
modifier = Modifier
.clickable { onReaction(emoji) }
.padding(horizontal = 4.dp),
fontSize = 24.sp
)
}
}
val itemCount = menuItems.size
menuItems.forEachIndexed { index, (label, icon) ->
@@ -144,12 +144,15 @@ class MessageManager(private val nostr: Nostr) {
private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) {
try {
val isReaction = rumor.kind().asStd() == KindStandard.REACTION
val kValue = if (isReaction) "reaction" else "dm"
// Construct reference tags
val tags = listOf(
Tag.identifier(giftId.toHex()),
Tag.publicKey(rumor.author()),
Tag.custom("r", listOf(rumor.roomId().toString())),
Tag.custom("k", listOf("14"))
Tag.custom("k", listOf("14", kValue))
)
// Set event kind
@@ -348,4 +351,64 @@ class MessageManager(private val nostr: Nostr) {
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)
}
}
}
@@ -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()) {
val currentUser = nostr.signer.getPublicKeyAsync() ?: return
val isReaction = event.kind().asStd() == KindStandard.REACTION
_state.update { currentState ->
val rooms = currentState.rooms.toMutableMap()
@@ -256,10 +277,11 @@ class ChatRepository(
// New room discovery
val newRoom = Room.new(event, currentUser, roomId).copy(
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
} else if (event.createdAt().asSecs() >= existingRoom.createdAt.asSecs()) {
} else if (!isReaction && event.createdAt().asSecs() >= existingRoom.createdAt.asSecs()) {
// Only update preview if message is newer (handles sync/late arrivals)
rooms[roomId] = existingRoom.copy(
lastMessage = event.content(),
@@ -268,10 +290,10 @@ class ChatRepository(
unreadCount = if (isFromMe) existingRoom.unreadCount else existingRoom.unreadCount + 1
)
} 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)
} 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
}
currentState.copy(rooms = rooms)
@@ -74,4 +74,8 @@ class ChatScreenViewModel(
fun sendFileMessage(file: ByteArray?, type: String?) {
chatRepository.sendFileMessage(id, file, type)
}
fun sendReaction(targetEventId: EventId, reaction: String) {
chatRepository.sendReaction(id, targetEventId, reaction)
}
}