feat: add support for reply message #44

Merged
reya merged 3 commits from feat/reply-message into master 2026-07-14 10:22:30 +00:00
4 changed files with 90 additions and 58 deletions
Showing only changes of commit 32cfa4042c - Show all commits

View File

@@ -27,7 +27,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.boundsInWindow
@@ -41,6 +40,7 @@ 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 coil3.compose.AsyncImage import coil3.compose.AsyncImage
import rust.nostr.sdk.EventId
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.URL_REGEX import su.reya.coop.URL_REGEX
@@ -49,8 +49,9 @@ import su.reya.coop.isImageUrl
import su.reya.coop.removeImageUrls import su.reya.coop.removeImageUrls
@Immutable @Immutable
data class MessageUiModel( data class MessageModel(
val id: String, val id: EventId,
val author: PublicKey,
val annotatedContent: AnnotatedString, val annotatedContent: AnnotatedString,
val images: List<String>, val images: List<String>,
val timestamp: String, val timestamp: String,
@@ -58,18 +59,13 @@ data class MessageUiModel(
) )
@Composable @Composable
fun rememberMessageUiModel( fun rememberMessageModel(event: UnsignedEvent, currentUser: PublicKey? = null): MessageModel {
event: UnsignedEvent, return remember(event, currentUser) {
currentUserPublicKey: PublicKey?, val id = event.ensureId().id()!!
contentColor: Color val isMine = currentUser == event.author()
): MessageUiModel {
return remember(event, currentUserPublicKey, contentColor) {
val content = event.content() val content = event.content()
val images = URL_REGEX.findAll(content)
.map { it.value }
.filter { it.isImageUrl() }
.toList()
val images = URL_REGEX.findAll(content).map { it.value }.filter { it.isImageUrl() }.toList()
val cleanedContent = content.removeImageUrls() val cleanedContent = content.removeImageUrls()
val annotatedString = buildAnnotatedString { val annotatedString = buildAnnotatedString {
@@ -82,7 +78,6 @@ fun rememberMessageUiModel(
url = url, url = url,
styles = TextLinkStyles( styles = TextLinkStyles(
style = SpanStyle( style = SpanStyle(
color = contentColor,
textDecoration = TextDecoration.Underline, textDecoration = TextDecoration.Underline,
fontWeight = FontWeight.Medium fontWeight = FontWeight.Medium
) )
@@ -96,19 +91,20 @@ fun rememberMessageUiModel(
append(cleanedContent.substring(lastIndex)) append(cleanedContent.substring(lastIndex))
} }
MessageUiModel( MessageModel(
id = event.id()?.toHex() ?: event.hashCode().toString(), id = id,
author = event.author(),
annotatedContent = annotatedString, annotatedContent = annotatedString,
images = images, images = images,
timestamp = event.createdAt().formatAsTime(), timestamp = event.createdAt().formatAsTime(),
isMine = event.author() == currentUserPublicKey isMine = isMine,
) )
} }
} }
@Composable @Composable
fun ChatMessage( fun ChatMessage(
model: MessageUiModel, model: MessageModel,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onLongClick: (Rect) -> Unit = {} onLongClick: (Rect) -> Unit = {}
) { ) {

View File

@@ -43,7 +43,6 @@ import androidx.compose.material3.DropdownMenuGroup
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.LoadingIndicator
@@ -84,7 +83,6 @@ 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
import coop.composeapp.generated.resources.ic_copy import coop.composeapp.generated.resources.ic_copy
import coop.composeapp.generated.resources.ic_info
import coop.composeapp.generated.resources.ic_reply import coop.composeapp.generated.resources.ic_reply
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -97,7 +95,7 @@ import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Room import su.reya.coop.Room
import su.reya.coop.RoomUiState import su.reya.coop.RoomUiState
import su.reya.coop.Screen import su.reya.coop.Screen
import su.reya.coop.formatAsGroupHeader import su.reya.coop.formatAsGroup
import su.reya.coop.shared.Avatar import su.reya.coop.shared.Avatar
import su.reya.coop.uiStateFlow import su.reya.coop.uiStateFlow
import su.reya.coop.viewmodel.AccountViewModel import su.reya.coop.viewmodel.AccountViewModel
@@ -138,19 +136,20 @@ fun ChatScreen(
return return
} }
val loading = viewModel.loading
val newOtherMessages = viewModel.newOtherMessages
val requireScreening = viewModel.requireScreening
val messages = viewModel.messages
val groupedMessages =
remember { derivedStateOf { messages.groupBy { it.createdAt().formatAsGroup() } } }
val roomState by (room as Room).uiStateFlow(profileCache, currentUser?.publicKey) val roomState by (room as Room).uiStateFlow(profileCache, currentUser?.publicKey)
.collectAsStateWithLifecycle(RoomUiState()) .collectAsStateWithLifecycle(RoomUiState())
var text by remember { mutableStateOf("") } var text by remember { mutableStateOf("") }
var selectedMessage by remember { mutableStateOf<Pair<MessageUiModel, Rect>?>(null) } var selectedMessage by remember { mutableStateOf<Pair<MessageModel, Rect>?>(null) }
var replyingTo by remember { mutableStateOf<MessageModel?>(null) }
val loading = viewModel.loading
val newOtherMessages = viewModel.newOtherMessages
val requireScreening = viewModel.requireScreening
val messages = viewModel.messages
val groupedMessages =
remember { derivedStateOf { messages.groupBy { it.createdAt().formatAsGroupHeader() } } }
val blurAmount by animateDpAsState( val blurAmount by animateDpAsState(
targetValue = if (selectedMessage != null) 8.dp else 0.dp, targetValue = if (selectedMessage != null) 8.dp else 0.dp,
@@ -267,9 +266,6 @@ fun ChatScreen(
room?.let { ScreenerCard(accountViewModel, it) } room?.let { ScreenerCard(accountViewModel, it) }
} }
val mineColor = MaterialTheme.colorScheme.onPrimaryContainer
val otherColor = MaterialTheme.colorScheme.onSurface
when (messages.isNotEmpty()) { when (messages.isNotEmpty()) {
true -> { true -> {
LazyColumn( LazyColumn(
@@ -283,24 +279,19 @@ fun ChatScreen(
groupedMessages.value.forEach { (dateHeader, messagesInGroup) -> groupedMessages.value.forEach { (dateHeader, messagesInGroup) ->
items( items(
items = messagesInGroup, items = messagesInGroup,
key = { it.id()?.toHex() ?: it.hashCode().toString() } key = { it.ensureId().id()?.toHex()!! }
) { event -> ) {
val isMine = currentUser?.publicKey == event.author() val model =
val uiModel = rememberMessageUiModel( rememberMessageModel(it, currentUser?.publicKey)
event = event,
currentUserPublicKey = currentUser?.publicKey,
contentColor = if (isMine) mineColor else otherColor
)
val isHighlighted =
selectedMessage?.first?.id == uiModel.id
ChatMessage( ChatMessage(
model = uiModel, model = model,
modifier = Modifier.graphicsLayer { modifier = Modifier.graphicsLayer {
alpha = if (isHighlighted) 0f else 1f alpha =
if (selectedMessage?.first?.id == model.id) 0f else 1f
}, },
onLongClick = { rect -> onLongClick = { rect ->
selectedMessage = uiModel to rect selectedMessage = model to rect
} }
) )
} }
@@ -373,6 +364,9 @@ fun ChatScreen(
} }
else -> { else -> {
replyingTo?.let {
ReplyBox(it) { replyingTo = null }
}
ChatInput( ChatInput(
value = text, value = text,
onValueChange = { text = it }, onValueChange = { text = it },
@@ -471,6 +465,10 @@ fun ChatScreen(
} }
} }
"Reply" -> {
replyingTo = model
}
else -> {} else -> {}
} }
selectedMessage = null selectedMessage = null
@@ -481,13 +479,54 @@ fun ChatScreen(
} }
} }
@Composable
private fun ReplyBox(model: MessageModel, onDismiss: () -> Unit) {
val profileCache = LocalProfileCache.current
val profileFlow = remember(model) { profileCache.getMetadata(model.author) }
val profile by profileFlow.collectAsStateWithLifecycle()
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable { onDismiss() },
color = MaterialTheme.colorScheme.tertiaryContainer,
shape = RoundedCornerShape(16.dp),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Text(
text = "Replying to ${profile?.name}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onTertiaryContainer.copy(
alpha = 0.6f
),
)
Text(
text = model.annotatedContent.toString().ifBlank {
if (model.images.isNotEmpty()) "[Image]" else ""
},
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
}
}
}
}
@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(
Triple("Copy", Res.drawable.ic_copy, false), "Copy" to Res.drawable.ic_copy,
Triple("Reply", Res.drawable.ic_reply, false), "Reply" to Res.drawable.ic_reply
Triple("Info", Res.drawable.ic_info, false),
) )
DropdownMenuGroup( DropdownMenuGroup(
@@ -497,7 +536,7 @@ private fun ContextMenu(onAction: (String) -> Unit) {
) { ) {
val itemCount = menuItems.size val itemCount = menuItems.size
menuItems.forEachIndexed { index, (label, icon, hasDivider) -> menuItems.forEachIndexed { index, (label, icon) ->
DropdownMenuItem( DropdownMenuItem(
shapes = MenuDefaults.itemShape(index, itemCount), shapes = MenuDefaults.itemShape(index, itemCount),
colors = MenuDefaults.selectableItemVibrantColors(), colors = MenuDefaults.selectableItemVibrantColors(),
@@ -511,11 +550,6 @@ private fun ContextMenu(onAction: (String) -> Unit) {
checked = false, checked = false,
onCheckedChange = { _ -> onAction(label) }, onCheckedChange = { _ -> onAction(label) },
) )
if (hasDivider) {
HorizontalDivider(
modifier = Modifier.padding(vertical = 4.dp),
)
}
} }
} }
} }

View File

@@ -150,7 +150,7 @@ fun Timestamp.ago(): String {
} }
} }
fun Timestamp.formatAsGroupHeader(): String { fun Timestamp.formatAsGroup(): String {
val timeZone = TimeZone.currentSystemDefault() val timeZone = TimeZone.currentSystemDefault()
val inputInstant = Instant.fromEpochSeconds(this.asSecs().toLong()) val inputInstant = Instant.fromEpochSeconds(this.asSecs().toLong())
val inputDate = inputInstant.toLocalDateTime(timeZone).date val inputDate = inputInstant.toLocalDateTime(timeZone).date

View File

@@ -9,6 +9,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import rust.nostr.sdk.EventId
import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.Profile import su.reya.coop.Profile
import su.reya.coop.Room import su.reya.coop.Room
@@ -62,8 +63,9 @@ class ChatScreenViewModel(
} }
} }
fun sendMessage(text: String) { fun sendMessage(text: String, replyTo: EventId? = null) {
chatRepository.sendMessage(id, text) val replyToList = if (replyTo != null) listOf(replyTo) else emptyList()
chatRepository.sendMessage(id, text, replyToList)
} }
fun sendFileMessage(file: ByteArray?, type: String?) { fun sendFileMessage(file: ByteArray?, type: String?) {