Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6f4a97778 | ||
|
|
7009dbf692 | ||
|
|
9a2bd3816f |
@@ -5,9 +5,13 @@ 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.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.offset
|
||||
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
|
||||
@@ -34,6 +38,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 +51,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 +66,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 +121,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 +138,8 @@ fun rememberMessageModel(event: UnsignedEvent, currentUser: PublicKey? = null):
|
||||
images = images,
|
||||
timestamp = event.createdAt().formatAsTime(),
|
||||
isMine = isMine,
|
||||
replyEventIds = replyEventIds
|
||||
replyEventIds = replyEventIds,
|
||||
reactions = groupedReactions
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -158,46 +186,108 @@ fun ChatMessage(
|
||||
horizontalAlignment = if (model.isMine) Alignment.End else Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
if (model.annotatedContent.isNotBlank()) {
|
||||
Surface(
|
||||
modifier = Modifier.widthIn(max = 280.dp),
|
||||
color = containerColor,
|
||||
contentColor = contentColor,
|
||||
shape = bubbleShape,
|
||||
Box(contentAlignment = Alignment.BottomEnd) {
|
||||
Column(
|
||||
modifier = Modifier.padding(
|
||||
bottom = if (model.reactions.isNotEmpty()) 4.dp else 0.dp,
|
||||
end = if (model.reactions.isNotEmpty() && !model.isMine) 8.dp else 0.dp
|
||||
),
|
||||
horizontalAlignment = if (model.isMine) Alignment.End else Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = model.annotatedContent,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
if (model.annotatedContent.isNotBlank()) {
|
||||
Surface(
|
||||
modifier = Modifier.widthIn(max = 280.dp),
|
||||
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 ->
|
||||
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
|
||||
|
||||
if (model.reactions.isNotEmpty()) {
|
||||
MessageReactions(
|
||||
reactions = model.reactions,
|
||||
modifier = Modifier.offset(y = 12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isMessageClicked) {
|
||||
Text(
|
||||
text = model.timestamp,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
modifier = Modifier.align(
|
||||
if (model.isMine) Alignment.End else Alignment.Start
|
||||
)
|
||||
modifier = Modifier.padding(top = if (model.reactions.isNotEmpty()) 8.dp else 0.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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.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
|
||||
@@ -90,6 +91,8 @@ import kotlinx.coroutines.Dispatchers
|
||||
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
|
||||
@@ -143,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)
|
||||
@@ -161,6 +175,29 @@ fun ChatScreen(
|
||||
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 ->
|
||||
scope.launch {
|
||||
// Read file on IO dispatcher
|
||||
@@ -190,15 +227,13 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(messages.size) {
|
||||
if (messages.isNotEmpty()) {
|
||||
LaunchedEffect(allEvents.size) {
|
||||
if (displayMessages.isNotEmpty()) {
|
||||
listState.animateScrollToItem(0)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Scaffold(
|
||||
modifier = Modifier.blur(blurAmount),
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime),
|
||||
@@ -271,7 +306,7 @@ fun ChatScreen(
|
||||
room?.let { ScreenerCard(accountViewModel, it) }
|
||||
}
|
||||
|
||||
when (messages.isNotEmpty()) {
|
||||
when (displayMessages.isNotEmpty()) {
|
||||
true -> {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
@@ -286,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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,7 +339,13 @@ fun ChatScreen(
|
||||
.animateItem(),
|
||||
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(
|
||||
model = model,
|
||||
modifier = Modifier.graphicsLayer {
|
||||
@@ -394,8 +436,9 @@ fun ChatScreen(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
onSend = {
|
||||
viewModel.sendMessage(text)
|
||||
viewModel.sendMessage(text, replyingTo?.id)
|
||||
text = ""
|
||||
replyingTo = null
|
||||
},
|
||||
onUpload = {
|
||||
fileLauncher.launch("image/*")
|
||||
@@ -435,14 +478,11 @@ fun ChatScreen(
|
||||
val (model, bounds) = selectedMessage ?: return@AnimatedVisibility
|
||||
|
||||
val density = LocalDensity.current
|
||||
val windowInfo = LocalWindowInfo.current
|
||||
val windowHeight = windowInfo.containerSize.height
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
var menuHeight by remember { mutableFloatStateOf(0f) }
|
||||
val spacing = with(density) { 12.dp.toPx() }
|
||||
val showAbove =
|
||||
(windowHeight - bounds.bottom) < (menuHeight + spacing) && bounds.top > (menuHeight + spacing)
|
||||
var toolbarHeight by remember { mutableFloatStateOf(0f) }
|
||||
val spacing = with(density) { 6.dp.toPx() }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -451,11 +491,30 @@ fun ChatScreen(
|
||||
.clickable { selectedMessage = null }
|
||||
.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() }
|
||||
|
||||
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(
|
||||
model = model,
|
||||
modifier = Modifier
|
||||
@@ -463,38 +522,35 @@ fun ChatScreen(
|
||||
.padding(horizontal = 16.dp)
|
||||
)
|
||||
|
||||
val menuOffset = if (showAbove) {
|
||||
bounds.top - menuHeight - spacing
|
||||
} else {
|
||||
bounds.bottom + spacing
|
||||
}
|
||||
|
||||
// Action Menu (Below)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset { IntOffset(0, menuOffset.toInt().coerceAtLeast(0)) }
|
||||
.offset { IntOffset(0, (bounds.bottom + spacing).toInt()) }
|
||||
.onGloballyPositioned { menuHeight = it.size.height.toFloat() }
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
contentAlignment = if (model.isMine) Alignment.CenterEnd else Alignment.CenterStart
|
||||
) {
|
||||
ContextMenu { action ->
|
||||
when (action) {
|
||||
"Copy" -> {
|
||||
scope.launch {
|
||||
val content = model.annotatedContent
|
||||
val data = ClipData.newPlainText(content, content)
|
||||
clipboardManager.setClipEntry(ClipEntry(data))
|
||||
ContextMenu(
|
||||
onAction = { action ->
|
||||
when (action) {
|
||||
"Copy" -> {
|
||||
scope.launch {
|
||||
val content = model.annotatedContent
|
||||
val data = ClipData.newPlainText(content, content)
|
||||
clipboardManager.setClipEntry(ClipEntry(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"Reply" -> {
|
||||
replyingTo = model
|
||||
}
|
||||
"Reply" -> {
|
||||
replyingTo = model
|
||||
}
|
||||
|
||||
else -> {}
|
||||
else -> {}
|
||||
}
|
||||
selectedMessage = null
|
||||
}
|
||||
selectedMessage = null
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -544,7 +600,11 @@ private fun ReplyBox(model: MessageModel, onDismiss: () -> Unit) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReplyPreview(event: UnsignedEvent, isMine: Boolean = false) {
|
||||
private fun ReplyPreview(
|
||||
event: UnsignedEvent,
|
||||
isMine: Boolean = false,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val profileCache = LocalProfileCache.current
|
||||
val profileFlow = remember(event) { profileCache.getMetadata(event.author()) }
|
||||
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
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.widthIn(max = 280.dp),
|
||||
modifier = Modifier
|
||||
.widthIn(max = 280.dp)
|
||||
.clickable(onClick = onClick),
|
||||
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
shape = bubbleShape,
|
||||
) {
|
||||
@@ -568,13 +630,13 @@ private fun ReplyPreview(event: UnsignedEvent, isMine: Boolean = false) {
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = profile?.name ?: "Unknown",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer.copy(
|
||||
alpha = 0.6f
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
)
|
||||
Text(
|
||||
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)
|
||||
@Composable
|
||||
private fun ContextMenu(onAction: (String) -> Unit) {
|
||||
private fun ContextMenu(
|
||||
onAction: (String) -> Unit
|
||||
) {
|
||||
val menuItems = listOf(
|
||||
"Copy" to Res.drawable.ic_copy,
|
||||
"Reply" to Res.drawable.ic_reply
|
||||
@@ -598,6 +692,8 @@ private fun ContextMenu(onAction: (String) -> Unit) {
|
||||
DropdownMenuGroup(
|
||||
shapes = MenuDefaults.groupShape(1, 1),
|
||||
containerColor = MenuDefaults.groupVibrantContainerColor,
|
||||
tonalElevation = 1.dp,
|
||||
shadowElevation = 1.dp,
|
||||
modifier = Modifier.width(220.dp)
|
||||
) {
|
||||
val itemCount = menuItems.size
|
||||
|
||||
@@ -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
|
||||
@@ -176,12 +179,13 @@ class MessageManager(private val nostr: Nostr) {
|
||||
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
|
||||
val kTag = SingleLetterTag.lowercase(Alphabet.K)
|
||||
|
||||
// Get all DM events
|
||||
val filter = Filter().kind(kind).customTags(kTag, listOf("14", "dm"))
|
||||
// Get all rumors (DMs and Reactions)
|
||||
val filter = Filter().kind(kind).customTags(kTag, listOf("14", "dm", "reaction"))
|
||||
val events = client?.database()?.query(filter)?.toVec() ?: return null
|
||||
|
||||
// Collect rooms
|
||||
val roomsMap: MutableMap<Long, Room> = mutableMapOf()
|
||||
val lastDmTimestampMap: MutableMap<Long, ULong> = mutableMapOf()
|
||||
|
||||
events
|
||||
.map { UnsignedEvent.fromJson(it.content()) }
|
||||
@@ -189,18 +193,41 @@ class MessageManager(private val nostr: Nostr) {
|
||||
.forEach { rumor ->
|
||||
val id = rumor.roomId()
|
||||
val isFromMe = rumor.author() == userPubkey
|
||||
val isReaction = rumor.kind().asStd() == KindStandard.REACTION
|
||||
val existing = roomsMap[id]
|
||||
val createdAt = rumor.createdAt()
|
||||
|
||||
// If the room is new or the current rumor is newer than the existing one
|
||||
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
|
||||
if (existing == null) {
|
||||
val room = Room.new(rumor = rumor, userPubkey = userPubkey, id = id)
|
||||
roomsMap[id] = if (isOngoing) room.copy(kind = RoomKind.Ongoing) else room
|
||||
} else if (isFromMe && existing.kind != RoomKind.Ongoing) {
|
||||
// If it's an older rumor but sent by the user, mark the room as Ongoing
|
||||
roomsMap[id] = existing.copy(kind = RoomKind.Ongoing)
|
||||
// If the first event we see is a reaction, don't use it as lastMessage
|
||||
roomsMap[id] = if (isReaction) {
|
||||
room.copy(lastMessage = null)
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
companion object {
|
||||
val BOOTSTRAP_RELAYS = listOf(
|
||||
"wss://relay.primal.net",
|
||||
"wss://relay.ditto.pub",
|
||||
"wss://user.kindpag.es",
|
||||
"wss://relay.primal.net",
|
||||
"wss://relay.nostr.net",
|
||||
"wss://profiles.nostr1.com",
|
||||
)
|
||||
|
||||
val INDEXER_RELAY = listOf(
|
||||
"wss://indexer.coracle.social",
|
||||
"wss://user.kindpag.es",
|
||||
)
|
||||
|
||||
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()) {
|
||||
val currentUser = nostr.signer.getPublicKeyAsync() ?: return
|
||||
val isReaction = event.kind().asStd() == KindStandard.REACTION
|
||||
|
||||
_state.update { currentState ->
|
||||
val rooms = currentState.rooms.toMutableMap()
|
||||
@@ -256,22 +277,24 @@ 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()) {
|
||||
// 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(
|
||||
lastMessage = event.content(),
|
||||
lastMessage = if (isReaction) existingRoom.lastMessage else event.content(),
|
||||
createdAt = event.createdAt(),
|
||||
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) {
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user