Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6f4a97778 |
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,6 +185,15 @@ 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)
|
||||||
|
) {
|
||||||
|
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)
|
||||||
) {
|
) {
|
||||||
if (model.annotatedContent.isNotBlank()) {
|
if (model.annotatedContent.isNotBlank()) {
|
||||||
Surface(
|
Surface(
|
||||||
@@ -188,16 +225,69 @@ fun ChatMessage(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (model.reactions.isNotEmpty()) {
|
||||||
|
MessageReactions(
|
||||||
|
reactions = model.reactions,
|
||||||
|
modifier = Modifier.offset(y = 12.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -91,6 +92,7 @@ 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.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
|
||||||
@@ -144,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)
|
||||||
@@ -170,7 +183,7 @@ fun ChatScreen(
|
|||||||
|
|
||||||
for (group in groupedMessages.value) {
|
for (group in groupedMessages.value) {
|
||||||
val msgInGroup = group.value
|
val msgInGroup = group.value
|
||||||
val idx = msgInGroup.indexOfFirst { it.id() == eventId }
|
val idx = msgInGroup.indexOfFirst { it.ensureId().id() == eventId }
|
||||||
if (idx != -1) {
|
if (idx != -1) {
|
||||||
targetIndex = currentIndex + idx
|
targetIndex = currentIndex + idx
|
||||||
break
|
break
|
||||||
@@ -214,8 +227,8 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(messages.size) {
|
LaunchedEffect(allEvents.size) {
|
||||||
if (messages.isNotEmpty()) {
|
if (displayMessages.isNotEmpty()) {
|
||||||
listState.animateScrollToItem(0)
|
listState.animateScrollToItem(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -293,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
|
||||||
@@ -308,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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -464,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
|
||||||
@@ -480,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
|
||||||
@@ -492,21 +522,17 @@ 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(
|
||||||
|
onAction = { action ->
|
||||||
when (action) {
|
when (action) {
|
||||||
"Copy" -> {
|
"Copy" -> {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -524,6 +550,7 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
selectedMessage = null
|
selectedMessage = null
|
||||||
}
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -622,9 +649,41 @@ private fun ReplyPreview(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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
|
||||||
@@ -633,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user