feat: add support for reply message (#44)

Reviewed-on: #44
This commit was merged in pull request #44.
This commit is contained in:
2026-07-14 10:22:27 +00:00
parent 0e789b5d91
commit 1485a767ed
5 changed files with 172 additions and 74 deletions

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,27 +49,25 @@ 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,
val isMine: Boolean val isMine: Boolean,
val replyEventIds: List<EventId>
) )
@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) val replyEventIds = event.tags().eventIds()
.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 +80,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 +93,21 @@ 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,
replyEventIds = replyEventIds
) )
} }
} }
@Composable @Composable
fun ChatMessage( fun ChatMessage(
model: MessageUiModel, model: MessageModel,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onLongClick: (Rect) -> Unit = {} onLongClick: (Rect) -> Unit = {}
) { ) {
@@ -146,14 +145,14 @@ fun ChatMessage(
} }
), ),
horizontalAlignment = if (model.isMine) Alignment.End else Alignment.Start, horizontalAlignment = if (model.isMine) Alignment.End else Alignment.Start,
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
if (model.annotatedContent.isNotBlank()) { if (model.annotatedContent.isNotBlank()) {
Surface( Surface(
modifier = Modifier.widthIn(max = 280.dp),
color = containerColor, color = containerColor,
contentColor = contentColor, contentColor = contentColor,
shape = bubbleShape, shape = bubbleShape,
modifier = Modifier.widthIn(max = 280.dp)
) { ) {
Text( Text(
text = model.annotatedContent, text = model.annotatedContent,

View File

@@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.union import androidx.compose.foundation.layout.union
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
@@ -43,7 +44,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,20 +84,20 @@ 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
import kotlinx.coroutines.launch 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.UnsignedEvent
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalProfileCache import su.reya.coop.LocalProfileCache
import su.reya.coop.LocalSnackbarHostState 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 +138,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,43 +268,49 @@ 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(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.fillMaxWidth(), .fillMaxWidth(),
contentPadding = PaddingValues(16.dp),
reverseLayout = true,
state = listState, state = listState,
reverseLayout = true,
contentPadding = PaddingValues(16.dp),
) { ) {
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 -> ) { event ->
val isMine = currentUser?.publicKey == event.author() val model =
val uiModel = rememberMessageUiModel( rememberMessageModel(event, currentUser?.publicKey)
event = event,
currentUserPublicKey = currentUser?.publicKey,
contentColor = if (isMine) mineColor else otherColor
)
val isHighlighted =
selectedMessage?.first?.id == uiModel.id
val replyPreview =
remember(model.replyEventIds, messages.size) {
model.replyEventIds.firstOrNull()
?.let { replyId ->
messages.find { it.id() == replyId }
}
}
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
replyPreview?.let { ReplyPreview(it, model.isMine) }
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
} }
) )
} }
}
item { item {
DateSeparator(dateHeader) DateSeparator(dateHeader)
} }
@@ -373,6 +380,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 +481,10 @@ fun ChatScreen(
} }
} }
"Reply" -> {
replyingTo = model
}
else -> {} else -> {}
} }
selectedMessage = null selectedMessage = null
@@ -481,13 +495,98 @@ 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,
)
}
}
}
}
@Composable
private fun ReplyPreview(event: UnsignedEvent, isMine: Boolean = false) {
val profileCache = LocalProfileCache.current
val profileFlow = remember(event) { profileCache.getMetadata(event.author()) }
val profile by profileFlow.collectAsStateWithLifecycle()
val bubbleShape = if (isMine) {
RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 20.dp, bottomEnd = 4.dp)
} else {
RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 4.dp, bottomEnd = 20.dp)
}
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = if (isMine) Alignment.CenterEnd else Alignment.CenterStart
) {
Surface(
modifier = Modifier.widthIn(max = 280.dp),
color = MaterialTheme.colorScheme.tertiaryContainer,
shape = bubbleShape,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Text(
text = profile?.name ?: "Unknown",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onTertiaryContainer.copy(
alpha = 0.6f
),
)
Text(
text = event.content(),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onTertiaryContainer,
maxLines = 1,
)
}
}
}
}
@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 +596,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 +610,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

@@ -1,7 +1,9 @@
package su.reya.coop.repository package su.reya.coop.repository
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -33,6 +35,7 @@ class ChatRepository(
private val nostr: Nostr, private val nostr: Nostr,
private val mediaRepository: MediaRepository, private val mediaRepository: MediaRepository,
private val scope: CoroutineScope, private val scope: CoroutineScope,
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
) : ErrorHost by createErrorHost() { ) : ErrorHost by createErrorHost() {
private val _state = MutableStateFlow(ChatState()) private val _state = MutableStateFlow(ChatState())
val state = _state.stateIn( val state = _state.stateIn(
@@ -58,7 +61,7 @@ class ChatRepository(
.stateIn(scope, SharingStarted.WhileSubscribed(5000), false) .stateIn(scope, SharingStarted.WhileSubscribed(5000), false)
init { init {
scope.launch { scope.launch(defaultDispatcher) {
nostr.waitUntilInitialized() nostr.waitUntilInitialized()
// Observe message sync progress // Observe message sync progress
@@ -137,7 +140,7 @@ class ChatRepository(
} }
fun refreshChatRooms() { fun refreshChatRooms() {
scope.launch { scope.launch(defaultDispatcher) {
try { try {
val rooms = nostr.messages.getChatRooms() ?: emptySet() val rooms = nostr.messages.getChatRooms() ?: emptySet()
_state.update { currentState -> _state.update { currentState ->
@@ -166,7 +169,7 @@ class ChatRepository(
} }
fun chatRoomConnect(roomId: Long) { fun chatRoomConnect(roomId: Long) {
scope.launch { scope.launch(defaultDispatcher) {
try { try {
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found") val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
val members = room.members val members = room.members
@@ -183,7 +186,7 @@ class ChatRepository(
showError("Message cannot be empty") showError("Message cannot be empty")
return return
} }
scope.launch { scope.launch(defaultDispatcher) {
try { try {
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found") val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
nostr.messages.sendMessage( nostr.messages.sendMessage(
@@ -193,7 +196,7 @@ class ChatRepository(
replies = replies, replies = replies,
onRumorCreated = { event -> onRumorCreated = { event ->
updateRoomList(roomId, event) updateRoomList(roomId, event)
scope.launch { _newEvents.tryEmit(event) } scope.launch(defaultDispatcher) { _newEvents.tryEmit(event) }
}, },
) )
} catch (e: Exception) { } catch (e: Exception) {

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
@@ -39,7 +40,7 @@ class ChatScreenViewModel(
private fun loadMessages() { private fun loadMessages() {
chatRepository.loadChatRoomMessages(id) { initialMessages -> chatRepository.loadChatRoomMessages(id) { initialMessages ->
messages.clear() messages.clear()
messages.addAll(initialMessages) messages.addAll(initialMessages.distinctBy { it.id() })
loading = false loading = false
} }
} }
@@ -52,7 +53,7 @@ class ChatScreenViewModel(
viewModelScope.launch { viewModelScope.launch {
chatRepository.newEvents.collect { event -> chatRepository.newEvents.collect { event ->
if (event.roomId() == id) { if (event.roomId() == id) {
if (event.id() !in messages.map { it.id() }) { if (messages.none { it.id() == event.id() }) {
messages.add(0, event) messages.add(0, event)
} }
} else { } else {
@@ -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?) {