wip: reaction
This commit is contained in:
@@ -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,24 +520,30 @@ fun ChatScreen(
|
||||
.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
|
||||
},
|
||||
onReaction = { reaction ->
|
||||
viewModel.sendReaction(model.id, reaction)
|
||||
selectedMessage = null
|
||||
}
|
||||
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) ->
|
||||
|
||||
Reference in New Issue
Block a user